From c2927ab9a6b74f522f0223af3581a2550d1e57d2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 18:47:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 2 - AGENTS.md | 24 +- Jenkinsfile | 203 +------- Jenkinsfile.service | 152 +----- README.md | 21 +- audit-service/AGENTS.md | 2 +- .../src/main/resources/application-dev.yml | 6 - .../src/main/resources/application-test.yml | 6 - .../src/main/resources/application.yml | 20 +- auth/AGENTS.md | 6 +- auth/pom.xml | 1 + .../AuthorizationFeignConfiguration.java | 14 + .../auth/client/AuthorizationSnapshot.java | 8 + .../auth/client/IamAuthorizationClient.java | 2 +- .../controller/InternalAccountController.java | 22 +- .../dto/OverseasSessionValidationDtos.java | 29 -- .../repository/AuthAccountRepository.java | 10 +- .../service/AccountManagementService.java | 2 +- .../com/baiyue/auth/service/AuthService.java | 15 +- .../OverseasSessionValidationService.java | 38 -- .../auth/service/SessionStateService.java | 63 +++ .../auth/service/TokenSessionStore.java | 63 +-- auth/src/main/resources/application-dev.yml | 10 - auth/src/main/resources/application-test.yml | 15 - auth/src/main/resources/application.yml | 24 +- .../main/resources/openapi/auth-internal.yaml | 39 ++ ...ssionValidationControllerContractTest.java | 28 -- .../auth/service/AuthServiceSmallJwtTest.java | 8 +- .../OverseasSessionValidationServiceTest.java | 77 ---- .../service/SessionStateConcurrencyTest.java | 281 +++++++++++ .../auth/service/TokenSessionStoreTest.java | 4 +- common-core/AGENTS.md | 1 + common-core/pom.xml | 2 + .../core/outbox/EventDeliveryJournal.java | 59 +++ .../core/outbox/EventDeliveryProducer.java | 4 + .../outbox/EventOperationsTokenGuard.java | 20 + .../common/core/outbox/OutboxOperations.java | 129 ++++++ .../common/core/EventDeliveryJournalTest.java | 83 ++++ .../core/EventOperationsTokenGuardTest.java | 23 + .../common/core/OutboxOperationsTest.java | 88 ++++ common-event-contract/AGENTS.md | 4 +- .../baiyue/common/event/EventEnvelope.java | 6 +- .../common/event/EventEnvelopeValidator.java | 6 - .../com/baiyue/common/event/RegionCode.java | 13 - .../baiyue/common/event/DeliveryAckTest.java | 3 +- .../common/event/EventEnvelopeTest.java | 8 +- common-security/AGENTS.md | 5 +- .../BaiyueSecurityAutoConfiguration.java | 7 +- .../security/HttpSessionStateVerifier.java | 44 ++ .../RedisAccessTokenSessionValidator.java | 8 +- .../common/security/SessionStateVerifier.java | 7 + .../HttpSessionStateVerifierTest.java | 45 ++ .../security/JwtAuthenticationFilterTest.java | 2 +- .../RedisAccessTokenSessionValidatorTest.java | 10 +- .../security/UnifiedSessionModeTest.java | 2 +- common-web/pom.xml | 2 + .../web/EventDeliveryFeignConfiguration.java | 31 ++ .../common/web/EventDeliveryMetrics.java | 78 ++++ .../common/web/EventDeliveryObservation.java | 29 ++ ...tDeliveryObservationAutoConfiguration.java | 25 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../EventDeliveryFeignConfigurationTest.java | 25 + .../common/web/EventDeliveryMetricsTest.java | 71 +++ ...iveryObservationAutoConfigurationTest.java | 53 +++ .../web/EventDeliveryObservationTest.java | 22 + config/nacos/cn/README.md | 5 - config/nacos/cn/auth-service.yml | 17 - config/nacos/cn/event-bridge-service.yml | 15 - config/nacos/cn/finance-service.yml | 9 - config/nacos/local/README.md | 37 -- config/nacos/local/audit-service.yml | 5 - config/nacos/local/auth-service.yml | 15 - config/nacos/local/baiyue-common.yml | 73 --- config/nacos/local/event-bridge-service.yml | 11 - config/nacos/local/file-service.yml | 123 ----- config/nacos/local/finance-service.yml | 9 - config/nacos/local/firstleg-service.yml | 69 --- config/nacos/local/gateway-overseas.yml | 16 - config/nacos/local/gateway.yml | 11 - config/nacos/local/iam-service.yml | 16 - config/nacos/local/integration-service.yml | 53 --- config/nacos/local/lastleg-service.yml | 29 -- config/nacos/local/order-service.yml | 11 - config/nacos/local/print-service.yml | 11 - .../local/warehouse-overseas-service.yml | 19 - config/nacos/local/warehouse-service.yml | 5 - config/nacos/overseas/README.md | 19 - config/nacos/overseas/audit-service.yml | 6 - config/nacos/overseas/baiyue-common.yml | 67 --- .../nacos/overseas/event-bridge-service.yml | 21 - config/nacos/overseas/file-service.yml | 13 - config/nacos/overseas/gateway-overseas.yml | 9 - config/nacos/overseas/integration-service.yml | 12 - config/nacos/overseas/print-service.yml | 6 - config/nacos/test/README.md | 9 + config/nacos/{cn => test}/audit-service.yml | 0 config/nacos/test/auth-service.yml | 12 + config/nacos/{cn => test}/baiyue-common.yml | 18 +- config/nacos/{cn => test}/file-service.yml | 0 config/nacos/test/finance-service.yml | 18 + .../nacos/{cn => test}/firstleg-service.yml | 12 +- config/nacos/{cn => test}/gateway.yml | 0 config/nacos/{cn => test}/iam-service.yml | 2 - .../{cn => test}/integration-service.yml | 0 .../{overseas => test}/lastleg-service.yml | 14 +- config/nacos/{cn => test}/order-service.yml | 0 config/nacos/{cn => test}/print-service.yml | 0 .../warehouse-overseas-service.yml | 0 .../nacos/{cn => test}/warehouse-service.yml | 0 deploy/.env.overseas-auth.example | 12 - deploy/TEST单机部署执行清单.md | 42 ++ deploy/docker-compose.audit.yml | 22 +- deploy/docker-compose.overseas.yml | 116 ----- deploy/docker-compose.rocketmq.yml | 2 +- deploy/docker-compose.yml | 168 ++++--- deploy/docker/Dockerfile | 2 +- deploy/env/.env.infra-test.example | 2 +- deploy/env/.env.test.example | 54 +++ deploy/scripts/deploy-blue-green-service.sh | 411 ----------------- deploy/scripts/deploy-built-service.sh | 436 ++---------------- deploy/scripts/deploy-development-service.sh | 160 ------- deploy/scripts/deploy-services.sh | 226 +-------- deploy/scripts/initialize-test-databases.sh | 56 +-- deploy/service-map.yml | 12 - deploy/sql/README.md | 16 +- deploy/sql/migration-scopes.json | 10 +- .../V1__event_delivery_retry_audit.sql | 22 + .../V2__event_delivery_attempt.sql | 23 + .../V5__event_delivery_retry_audit.sql | 22 + .../V6__event_delivery_attempt.sql | 23 + .../V6__event_delivery_retry_audit.sql | 22 + .../V7__event_delivery_attempt.sql | 23 + deploy/sql/头程数据库统一增量-MySQL8.sql | 177 +++---- deploy/sql/海外区域统一增量-MySQL8.sql | 155 ++----- docs/头程项目目录结构与服务说明.md | 34 +- file/src/main/resources/application-dev.yml | 6 - file/src/main/resources/application-test.yml | 6 - file/src/main/resources/application.yml | 19 +- finance/AGENTS.md | 4 +- .../controller/EventOperationsController.java | 56 +++ .../BusinessFeeSourceEventService.java | 6 +- .../service/WalletCommandEventService.java | 8 +- .../service/WalletResultOutboxRelay.java | 65 ++- .../src/main/resources/application-dev.yml | 11 - finance/src/main/resources/application.yml | 21 +- finance/src/main/resources/db/finance.sql | 35 ++ .../BusinessFeeSourceEventServiceTest.java | 7 +- .../WalletCommandEventServiceTest.java | 8 +- .../finance/WalletCommandTransactionTest.java | 2 +- .../finance/WalletResultOutboxLeaseTest.java | 22 +- .../baiyue/finance/WalletResultRetryTest.java | 35 +- .../EventOperationsControllerTest.java | 22 + firstleg/AGENTS.md | 2 + .../controller/EventOperationsController.java | 56 +++ .../CarrierScheduleNodeOutboxService.java | 3 +- .../LoadingCompletedOutboxRepository.java | 37 +- .../service/LoadingCompletedOutboxRelay.java | 86 ++-- .../LoadingCompletedOutboxService.java | 3 +- .../OverseasContainerEventConsumer.java | 6 +- .../OverseasDeliveryCompletedConsumer.java | 2 - .../OverseasInboundOrderDetainedConsumer.java | 4 +- .../src/main/resources/application-dev.yml | 11 - .../src/main/resources/application-test.yml | 6 - firstleg/src/main/resources/application.yml | 20 +- .../resources/db/phase2-inbound-schema.sql | 35 ++ .../EventOperationsControllerTest.java | 22 + .../LoadingCompletedOutboxRepositoryTest.java | 4 +- .../repository/OutboxLeaseDatabaseTest.java | 57 +++ .../LoadingCompletedOutboxRelayTest.java | 48 +- .../OverseasContainerEventConsumerTest.java | 5 +- ...OverseasDeliveryCompletedConsumerTest.java | 2 +- ...rseasInboundOrderDetainedConsumerTest.java | 4 +- gateway/AGENTS.md | 2 +- .../config/RedisTokenSessionWebFilter.java | 23 + gateway/src/main/resources/application-cn.yml | 10 - gateway/src/main/resources/application.yml | 29 +- .../RedisTokenSessionWebFilterTest.java | 58 +++ iam/AGENTS.md | 6 +- iam/pom.xml | 1 - .../iam/client/SecurityEventBridgeClient.java | 18 - .../com/baiyue/iam/dto/AuthorizationDtos.java | 8 + .../iam/dto/AuthorizationSnapshotRow.java | 14 + .../iam/mapper/AuthorizationQueryMapper.java | 122 +---- .../iam/mapper/IamMembershipRoleMapper.java | 3 +- .../baiyue/iam/repository/RbacRepository.java | 2 +- .../service/AuthorizationSnapshotService.java | 19 +- .../com/baiyue/iam/service/MemberService.java | 10 +- .../service/MerchantOnboardingService.java | 7 +- .../service/PlatformInternalUserService.java | 20 +- .../PlatformUserManagementService.java | 13 +- .../com/baiyue/iam/service/RbacService.java | 12 +- .../iam/service/SecurityEventOutboxRelay.java | 91 ---- .../service/SecurityEventOutboxService.java | 83 ---- .../iam/service/SecurityVersionService.java | 24 + ...enantAdministratorProvisioningService.java | 14 +- .../com/baiyue/iam/service/TenantService.java | 8 +- iam/src/main/resources/application-dev.yml | 6 - iam/src/main/resources/application-test.yml | 15 - iam/src/main/resources/application.yml | 20 +- .../resources/db/security-event-outbox.sql | 35 -- .../main/resources/db/security-version.sql | 16 + iam/src/main/resources/openapi/iam-v1.yaml | 46 ++ ...SecurityEventBridgeClientContractTest.java | 35 -- .../mapper/AuthorizationIdentitySqlTest.java | 24 +- .../SecurityEventSqlContractTest.java | 42 -- .../SecurityVersionSqlContractTest.java | 26 ++ .../AuthorizationSnapshotServiceTest.java | 82 ++-- .../AuthorizationSnapshotTransactionTest.java | 90 ++++ .../baiyue/iam/service/MemberServiceTest.java | 8 +- .../MerchantOnboardingServiceTest.java | 8 +- .../PlatformInternalUserServiceTest.java | 14 +- .../PlatformUserManagementServiceTest.java | 17 +- .../baiyue/iam/service/RbacServiceTest.java | 5 +- .../service/SecurityEventOutboxRelayTest.java | 93 ---- .../SecurityEventOutboxServiceTest.java | 103 ----- .../SecurityVersionTransactionTest.java | 46 ++ ...tAdministratorProvisioningServiceTest.java | 15 +- .../iam/service/TenantCustomerExportTest.java | 2 +- .../baiyue/iam/service/TenantServiceTest.java | 10 +- integration-service/AGENTS.md | 2 +- .../src/main/resources/application.yml | 16 +- lastleg-service/AGENTS.md | 12 +- lastleg-service/docs/business-index.md | 2 +- .../controller/EventOperationsController.java | 56 +++ .../DeliveryCompletedOutboxService.java | 5 +- .../OverseasFeeOutboxRepository.java | 36 +- .../service/OverseasFeeOutboxRelay.java | 68 ++- .../service/OverseasFeeOutboxService.java | 3 +- .../InboundUnloadCompletedOutboxRelay.java | 79 ++-- .../InboundUnloadCompletedOutboxService.java | 3 +- .../UnloadCompletedOutboxRepository.java | 38 +- .../controller/LastlegSecurityController.java | 53 --- .../security/dto/LastlegSecurityDtos.java | 39 -- .../LastlegSecurityProjectionRedisMirror.java | 36 -- .../LastlegSecurityProjectionService.java | 208 --------- .../LastlegSecurityValidationService.java | 82 ---- .../SecurityProjectionMonotonicity.java | 19 - .../InboundOrderDetainedOutboxService.java | 4 +- .../service/LoadingCompletedConsumer.java | 10 +- .../ContainerShippingNodeConsumer.java | 4 +- .../WalletCommandCompletedConsumer.java | 6 +- .../wallet/WalletCommandOutboxService.java | 3 +- .../src/main/resources/application.yml | 37 +- .../src/main/resources/db/lastleg.sql | 210 ++------- .../lastleg/client/DirectEventClientTest.java | 3 +- .../EventOperationsControllerTest.java | 22 + .../OverseasFeeOutboxRetryTest.java | 14 +- ...InboundUnloadCompletedOutboxRelayTest.java | 43 +- ...boundUnloadCompletedOutboxServiceTest.java | 1 - .../repository/OutboxLeaseDatabaseTest.java | 112 +++-- .../UnloadCompletedOutboxRepositoryTest.java | 8 +- .../security/LastlegSecurityContractTest.java | 103 ----- .../SecurityProjectionArchitectureTest.java | 49 -- ...astlegSecurityProjectionTransportTest.java | 56 --- .../LastlegSecurityValidationServiceTest.java | 69 --- .../SecurityProjectionMonotonicityTest.java | 28 -- .../service/LoadingCompletedConsumerTest.java | 44 +- .../ContainerShippingNodeConsumerTest.java | 5 +- order/src/main/resources/application-dev.yml | 6 - order/src/main/resources/application-test.yml | 17 - order/src/main/resources/application.yml | 19 +- pom.xml | 2 - print/AGENTS.md | 2 +- print/src/main/resources/application.yml | 17 +- scripts/README.md | 12 +- .../initialization-baseline-contract.test.mjs | 32 +- scripts/agents-index-contract.ps1 | 75 ++- scripts/audit-deployment-contract.test.mjs | 4 +- .../blue-green-deployment-contract.test.mjs | 104 ----- .../blue-green-deployment-runtime.test.mjs | 165 ------- scripts/container-logging-contract.test.mjs | 7 +- ...ema-alignment-migrations-contract.test.mjs | 13 +- .../deploy-built-service-rollback.test.mjs | 37 +- scripts/event-outbox-operations.mjs | 50 ++ scripts/event-outbox-operations.test.mjs | 23 + scripts/finance-deployment-contract.test.mjs | 8 +- scripts/java-topology-contract.test.mjs | 31 +- scripts/overseas-deployment-contract.test.mjs | 245 ++-------- scripts/publish-local-nacos-config.ps1 | 49 -- scripts/publish-nacos-config.ps1 | 79 +--- scripts/synchronous-dependency-baseline.mjs | 36 +- .../synchronous-dependency-contract.test.mjs | 53 +-- ...est-environment-pipeline-contract.test.mjs | 158 ++----- scripts/use-nacos-environment.ps1 | 85 +--- warehouse-overseas-service/AGENTS.md | 2 +- .../src/main/resources/application.yml | 34 +- warehouse/AGENTS.md | 1 + .../src/main/resources/application-dev.yml | 6 - .../src/main/resources/application-test.yml | 15 - warehouse/src/main/resources/application.yml | 19 +- .../main/resources/openapi/warehouse-v1.yaml | 55 ++- 291 files changed, 4008 insertions(+), 6298 deletions(-) delete mode 100644 audit-service/src/main/resources/application-dev.yml delete mode 100644 audit-service/src/main/resources/application-test.yml create mode 100644 auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java delete mode 100644 auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java delete mode 100644 auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java create mode 100644 auth/src/main/java/com/baiyue/auth/service/SessionStateService.java delete mode 100644 auth/src/main/resources/application-dev.yml delete mode 100644 auth/src/main/resources/application-test.yml create mode 100644 auth/src/main/resources/openapi/auth-internal.yaml delete mode 100644 auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java delete mode 100644 auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java create mode 100644 common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java create mode 100644 common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java delete mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java create mode 100644 common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java create mode 100644 common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java create mode 100644 common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java delete mode 100644 config/nacos/cn/README.md delete mode 100644 config/nacos/cn/auth-service.yml delete mode 100644 config/nacos/cn/event-bridge-service.yml delete mode 100644 config/nacos/cn/finance-service.yml delete mode 100644 config/nacos/local/README.md delete mode 100644 config/nacos/local/audit-service.yml delete mode 100644 config/nacos/local/auth-service.yml delete mode 100644 config/nacos/local/baiyue-common.yml delete mode 100644 config/nacos/local/event-bridge-service.yml delete mode 100644 config/nacos/local/file-service.yml delete mode 100644 config/nacos/local/finance-service.yml delete mode 100644 config/nacos/local/firstleg-service.yml delete mode 100644 config/nacos/local/gateway-overseas.yml delete mode 100644 config/nacos/local/gateway.yml delete mode 100644 config/nacos/local/iam-service.yml delete mode 100644 config/nacos/local/integration-service.yml delete mode 100644 config/nacos/local/lastleg-service.yml delete mode 100644 config/nacos/local/order-service.yml delete mode 100644 config/nacos/local/print-service.yml delete mode 100644 config/nacos/local/warehouse-overseas-service.yml delete mode 100644 config/nacos/local/warehouse-service.yml delete mode 100644 config/nacos/overseas/README.md delete mode 100644 config/nacos/overseas/audit-service.yml delete mode 100644 config/nacos/overseas/baiyue-common.yml delete mode 100644 config/nacos/overseas/event-bridge-service.yml delete mode 100644 config/nacos/overseas/file-service.yml delete mode 100644 config/nacos/overseas/gateway-overseas.yml delete mode 100644 config/nacos/overseas/integration-service.yml delete mode 100644 config/nacos/overseas/print-service.yml create mode 100644 config/nacos/test/README.md rename config/nacos/{cn => test}/audit-service.yml (100%) create mode 100644 config/nacos/test/auth-service.yml rename config/nacos/{cn => test}/baiyue-common.yml (81%) rename config/nacos/{cn => test}/file-service.yml (100%) create mode 100644 config/nacos/test/finance-service.yml rename config/nacos/{cn => test}/firstleg-service.yml (87%) rename config/nacos/{cn => test}/gateway.yml (100%) rename config/nacos/{cn => test}/iam-service.yml (84%) rename config/nacos/{cn => test}/integration-service.yml (100%) rename config/nacos/{overseas => test}/lastleg-service.yml (77%) rename config/nacos/{cn => test}/order-service.yml (100%) rename config/nacos/{cn => test}/print-service.yml (100%) rename config/nacos/{overseas => test}/warehouse-overseas-service.yml (100%) rename config/nacos/{cn => test}/warehouse-service.yml (100%) delete mode 100644 deploy/.env.overseas-auth.example create mode 100644 deploy/TEST单机部署执行清单.md delete mode 100644 deploy/docker-compose.overseas.yml create mode 100644 deploy/env/.env.test.example delete mode 100644 deploy/scripts/deploy-blue-green-service.sh delete mode 100644 deploy/scripts/deploy-development-service.sh create mode 100644 deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql create mode 100644 deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql delete mode 100644 file/src/main/resources/application-dev.yml delete mode 100644 file/src/main/resources/application-test.yml create mode 100644 finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java delete mode 100644 finance/src/main/resources/application-dev.yml create mode 100644 finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java delete mode 100644 firstleg/src/main/resources/application-dev.yml delete mode 100644 firstleg/src/main/resources/application-test.yml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java delete mode 100644 gateway/src/main/resources/application-cn.yml create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java delete mode 100644 iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java create mode 100644 iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java delete mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java delete mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java delete mode 100644 iam/src/main/resources/application-dev.yml delete mode 100644 iam/src/main/resources/application-test.yml delete mode 100644 iam/src/main/resources/db/security-event-outbox.sql create mode 100644 iam/src/main/resources/db/security-version.sql delete mode 100644 iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java delete mode 100644 iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java delete mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java delete mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java delete mode 100644 order/src/main/resources/application-dev.yml delete mode 100644 order/src/main/resources/application-test.yml delete mode 100644 scripts/blue-green-deployment-contract.test.mjs delete mode 100644 scripts/blue-green-deployment-runtime.test.mjs create mode 100644 scripts/event-outbox-operations.mjs create mode 100644 scripts/event-outbox-operations.test.mjs delete mode 100644 scripts/publish-local-nacos-config.ps1 delete mode 100644 warehouse/src/main/resources/application-dev.yml delete mode 100644 warehouse/src/main/resources/application-test.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3fd5c31fe..4742c078c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -89,7 +89,6 @@ OpenAPI有效性: expire_in: 7 days paths: - gateway/target/*.jar - - gateway-overseas/target/*.jar - auth/target/*.jar - iam/target/*.jar - order/target/*.jar @@ -102,4 +101,3 @@ OpenAPI有效性: - print/target/*.jar - audit-service/target/*.jar - integration-service/target/*.jar - - event-bridge-service/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index df742e218..78460ce9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,18 +2,20 @@ > 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。 +根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。 + ## 适用范围与定位 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 -- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 +- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;统一 Token、API 和可靠直接投递公共契约见 `../docs/统一认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 ## 不可突破的产品与数据边界 - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。 -- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 +- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,所有端公网入口归 `gateway`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 ## 服务路由(按关键词定位) @@ -51,9 +53,9 @@ | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | | 目的港码头拖车与还空预警 | `firstleg/` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | -| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | -| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | +| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` | @@ -62,8 +64,8 @@ | 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | -| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | -| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | +| 统一 API 路由、Token、Audience/JWKS | `gateway/`、`auth/`、`common-security/` | +| 统一会话与账号/客户权限撤销 | `auth/`、`iam/`、`common-security/` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | | 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | @@ -76,7 +78,7 @@ | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | -| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | +| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | ## 公共模块快速索引 @@ -97,8 +99,8 @@ ## 国内头程与海外仓协同 -- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。原双 Token 与 Lastleg 安全投影在替代撤销链验证后移除。 -- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;现有 Bridge 尚未退役,不得先停服务后补替代链。 +- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。当前源码已移除旧海外会话验证与 Lastleg 安全投影,改为 Auth 权威会话校验;本地验证不等于测试环境部署验收。 +- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。 - `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 ## 契约、数据库与验证入口 @@ -113,7 +115,7 @@ - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。 - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 -- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 +- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 @@ -139,7 +141,7 @@ - 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md` - 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取 - 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取 -- 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` +- 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 diff --git a/Jenkinsfile b/Jenkinsfile index 861c71258..047ec59ad 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,11 +1,10 @@ def serviceModules = [ - 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service' ] def serviceCheckboxes = [ 'gateway': 'SERVICE_GATEWAY', - 'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS', 'auth': 'SERVICE_AUTH', 'iam': 'SERVICE_IAM', 'firstleg': 'SERVICE_FIRSTLEG', @@ -17,20 +16,19 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', - 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] def fullRebuildPaths = [ 'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/', - 'deploy/docker-compose.yml', 'deploy/service-map.yml' + 'deploy/docker-compose.yml', 'deploy/service-map.yml', 'config/nacos/test/', 'deploy/scripts/', 'Jenkinsfile' ] def commonPathServices = [ 'common-web/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'integration-service', 'lastleg-service', 'warehouse-overseas-service', - 'event-bridge-service', 'audit-service' + 'audit-service' ], 'common-security/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', @@ -39,20 +37,16 @@ def commonPathServices = [ 'common-openapi/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'integration-service', 'lastleg-service', 'warehouse-overseas-service', - 'event-bridge-service', 'audit-service' + 'audit-service' ], 'common-audit/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'warehouse-overseas-service', 'audit-service' ], 'common-event-contract/': [ - 'firstleg', 'iam', 'lastleg-service', 'event-bridge-service' - ], - 'deploy/docker-compose.overseas.yml': [ - 'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service' + 'firstleg', 'finance', 'lastleg-service' ], 'deploy/docker-compose.audit.yml': ['audit-service'], - 'deploy/docker-compose.rocketmq.yml': ['event-bridge-service'] ] def databaseMigrationPaths = ['deploy/sql/'] @@ -63,45 +57,6 @@ def downstreamResults = [:] def downstreamBuildUrls = [:] def downstreamBuildNumbers = [:] -def sendProductionWeComNotification(String title, String status, String color, String detail) { - node('java-builder') { - withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { - String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '未解析' - String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—' - String content = """## ${title} -> 应用:后端服务 -> Job:${env.JOB_NAME} -> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) -> 提交:${sourceVersion} -> 状态:${status} -${detail} -> 耗时:${duration} -> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} -""" - String payloadFile = 'wecom-production-notification.json' - writeFile( - file: payloadFile, - text: groovy.json.JsonOutput.toJson([ - msgtype: 'markdown', - markdown: [content: content] - ]), - encoding: 'UTF-8' - ) - sh ''' - set -eu - payload_file='wecom-production-notification.json' - trap 'rm -f "$payload_file"' EXIT HUP INT TERM - response="$(curl --fail --silent --show-error \ - -H 'Content-Type: application/json' \ - --data-binary @"$payload_file" \ - "$WECOM_WEBHOOK")" - printf '%s\n' "$response" - printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' - ''' - } - } -} - def parseAutoBuildState(String description, List allowedServices) { if (!description?.startsWith('AUTO ')) return null int markerIndex = description.indexOf(' | ') @@ -154,12 +109,8 @@ def environmentChoicesForJob(String jobBaseName) { def fixedEnvironmentForJob(String jobBaseName) { switch (jobBaseName) { - case 'backend-java-dev': - return 'development' case 'backend-java': return 'test' - case 'backend-java-prod': - return 'production' default: return null } @@ -167,12 +118,8 @@ def fixedEnvironmentForJob(String jobBaseName) { def fixedDownstreamJobPrefixForJob(String jobBaseName) { switch (jobBaseName) { - case 'backend-java-dev': - return 'baiyue-firstleg-dev-' case 'backend-java': return 'baiyue-firstleg-' - case 'backend-java-prod': - return 'baiyue-firstleg-prod-' default: return null } @@ -195,9 +142,8 @@ pipeline { parameters { choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务') booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)') - booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)') booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)') - booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') + booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '客户与权限服务(iam)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)') booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)') @@ -207,14 +153,12 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') - booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库地址') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') } @@ -230,7 +174,7 @@ pipeline { } if (params.ENVIRONMENT != resolvedEnvironment || params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) { - echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}" + error('当前流水线仅允许 TEST 与 baiyue-firstleg- 下游前缀') } env.RESOLVED_ENVIRONMENT = resolvedEnvironment echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" @@ -239,16 +183,7 @@ pipeline { env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() - if (env.RESOLVED_ENVIRONMENT == 'production') { - sh ''' - set -eu - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { - echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2 - exit 1 - } - ''' - } + String requestedRepository = params.IMAGE_REPOSITORY?.trim() if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { @@ -262,9 +197,7 @@ pipeline { env.RESOLVED_JOB_PREFIX = resolvedJobPrefix env.DATABASE_CHANGE_DETECTED = 'false' - if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { - error('生产环境禁止跳过测试') - } + List selected = [] if (params.BUILD_MODE == 'ALL') { @@ -326,15 +259,7 @@ pipeline { } selected = selected.unique() - if (params.DEPLOY && selected.contains('event-bridge-service')) { - String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() - if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { - error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') - } - if (bridgeRegion == 'SELECT') { - echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥' - } - } + env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { @@ -426,14 +351,6 @@ pipeline { String service = selectedService branches[service] = { String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" - boolean deployBothBridges = service == 'event-bridge-service' && - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) - String downstreamImageTag = deployBothBridges - ? "${env.IMAGE_TAG}-both" - : env.IMAGE_TAG - String downstreamBridgeRegion = deployBothBridges - ? 'CN' - : (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN') echo "触发单服务 Job: ${jobName}" def downstreamBuild = build( job: jobName, @@ -441,16 +358,13 @@ pipeline { propagate: false, parameters: [ string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), - string(name: 'IMAGE_TAG', value: downstreamImageTag), + string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), booleanParam(name: 'DEPLOY', value: params.DEPLOY), // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 booleanParam(name: 'SKIP_TESTS', value: true), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), - string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), - // Choice 参数始终传递所有新旧 Job 都接受的单区值; - // 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。 - string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion) + string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE) ] ) downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' @@ -483,89 +397,4 @@ pipeline { } } - post { - success { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') { - try { - List deployedServices = env.SELECTED_SERVICES?.split(',') - ?.collect { it.trim() } - ?.findAll { it } ?: [] - String serviceSummary = deployedServices.size() <= 3 - ? deployedServices.join('、') - : "${deployedServices.size()} 个服务" - sendProductionWeComNotification( - '✅ 生产部署成功', - '成功', - 'info', - "> 结果:${deployedServices.size()} 个服务全部成功\n" + - "> 部署服务:${serviceSummary ?: '—'}" - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - unstable { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - List failedServices = downstreamResults.findAll { service, result -> result != 'SUCCESS' } - .collect { service, result -> service } - int successCount = downstreamResults.findAll { service, result -> result == 'SUCCESS' }.size() - String failedLinks = failedServices.collect { service -> - String url = downstreamBuildUrls[service] - String number = downstreamBuildNumbers[service] - String label = number ? "${service} #${number}" : service - url ? "[${label}](${url})" : label - }.join('、') - sendProductionWeComNotification( - '⚠️ 生产部署部分失败', - '部分失败', - 'warning', - "> 结果:成功 ${successCount} / 失败 ${failedServices.size()}\n" + - "> 失败服务:${failedLinks ?: '未解析'}\n" + - '> 说明:点击失败服务可直接查看下游构建日志' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - failure { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - sendProductionWeComNotification( - '❌ 生产发布失败', - '失败', - 'warning', - '> 说明:生产发布流程失败,请打开父构建日志查看失败阶段' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - aborted { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - sendProductionWeComNotification( - '⏹ 生产部署已取消', - '已取消', - 'comment', - '> 说明:生产部署流程被人工取消或中止' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - } - } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 2f213a75f..1b34f46dc 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,11 +1,10 @@ def serviceModules = [ - 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service' ] def serviceJars = [ 'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', - 'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', @@ -17,18 +16,11 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', - 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] def fixedEnvironmentForServiceJob(String jobBaseName) { - if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) { - return 'development' - } - if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) { - return 'production' - } - if (jobBaseName?.startsWith('baiyue-firstleg-')) { + if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) { return 'test' } return null @@ -53,11 +45,11 @@ pipeline { string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境') + booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 依次部署国内和海外实例;SELECT 为总控兼容值') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') } environment { @@ -98,12 +90,7 @@ pipeline { error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") } if (params.ENVIRONMENT != resolvedEnvironment) { - echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}" - } - boolean deployBothBridges = service == 'event-bridge-service' && - imageTag?.endsWith('-both') - if (deployBothBridges) { - imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) + error('当前单服务流水线仅允许 TEST') } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') @@ -123,14 +110,8 @@ pipeline { env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_REPOSITORY = imageRepository - env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false' - if (service == 'event-bridge-service' && params.DEPLOY) { - String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() - if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { - error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') - } - } + } sh ''' @@ -142,15 +123,6 @@ pipeline { test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" ''' - sh ''' - if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || { - echo "生产构建只允许已经合并到 main 的提交" >&2 - exit 1 - } - fi - ''' script { currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}" @@ -163,15 +135,11 @@ pipeline { steps { script { boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean - if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { - error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') - } - boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS + + boolean skipTestExecution = params.SKIP_TESTS String testOption = skipTestExecution ? '-DskipTests' : '' if (params.SKIP_TESTS && coordinatorPinnedCommit) { echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" - } else if (env.RESOLVED_ENVIRONMENT == 'test') { - echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' } sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" } @@ -216,105 +184,27 @@ pipeline { } } - stage('部署开发或测试环境') { - when { - beforeAgent true - expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) } - } + stage('部署 TEST') { + when { expression { params.DEPLOY } } agent none steps { script { - String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot" - String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" - String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" - String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' - // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 - // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 - lock(resource: serviceDeployResource, inversePrecedence: true) { - lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { - node(targetLabel) { - deleteDir() - try { - unstash 'deployment-files' - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_IS_LOCAL=true', - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" - } - } - } - } finally { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } - } - } - } - } - } - - stage('部署生产环境单个服务') { - when { - beforeAgent true - expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } - } - agent { label 'java-builder' } - steps { - deleteDir() - unstash 'deployment-files' - script { - String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" - String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - lock(resource: serviceDeployResource, inversePrecedence: true) { + lock(resource: "firstleg-test-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) { + node('java-test') { + deleteDir() + unstash 'deployment-files' withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), - sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), - string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), - file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + file(credentialsId: 'firstleg-test-env', variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署生产事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services', - 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" - } + withEnv(['TARGET_IS_LOCAL=true', "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) { + sh 'sh deploy/scripts/deploy-built-service.sh test "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"' } } + } } } } - post { - always { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } } } diff --git a/README.md b/README.md index cac61cb47..d1cf1d8a7 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,21 @@ 本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。 -整体迁移尚未完成:下面模块清单与旧启动配置描述的是现有源码,海外 Gateway、事件桥和双 Token 仍待替代;不代表需要继续规划双区域或其他环境。 +根 POM 与发布清单已收敛为 20 个模块、13 个服务;本地配置变化尚未经过真实 TEST 部署验收。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,已不参与当前聚合构建和发布。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 ## 模块 +- `gateway`:四端统一 API 入口。 +- `finance`:财务账单、费用、充值、授信与资金核销。 - `auth`:统一登录、令牌签发与刷新。 - `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。 - `order`:订单主数据及订单状态编排。 - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 -- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 +- `lastleg-service`、`warehouse-overseas-service`:海外尾程/入仓/出库/派送和海外仓储资源,统一会话鉴权。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 @@ -31,21 +32,17 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -现有本地启动入口包含 IAM、Auth、Firstleg、Order、Warehouse、File、Print 和 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。旧海外 Compose/Nacos 仍存在;新的测试启动依赖和统一配置按部署主契约收敛,本地编译不代表另有开发环境。 - -国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 +唯一发布配置为 `deploy/docker-compose.yml`、`deploy/env/.env.test.example` 和 `config/nacos/test/`;所有保留服务共用实际 Namespace ID、Auth/Redis 会话和单机内部网络,各服务 Schema 保留。往来单位由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。发布、健康检查和失败恢复见 `deploy/TEST单机部署执行清单.md`。真实凭据不得提交;本地编译不代表另有开发环境。 ## 容器日志 -所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 +所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。统一业务及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 -单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖: +单容器默认保留 `50m × 3`,可在 TEST 部署变量中覆盖: | 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 | |---|---|---:|---:|---| -| 开发 | `json-file` | `20m` | `2` | 不要求 | | 测试 | `json-file` | `50m` | `3` | 不要求 | -| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 | 当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。 @@ -53,7 +50,7 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 ## 网关跨域配置 -本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: +本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到 TEST 时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: ```text CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com @@ -64,3 +61,5 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。 + +前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。 diff --git a/audit-service/AGENTS.md b/audit-service/AGENTS.md index 3f9ba8e89..781ddd2ab 100644 --- a/audit-service/AGENTS.md +++ b/audit-service/AGENTS.md @@ -17,7 +17,7 @@ | 入库编排 | `service/AuditIngestService.java` | | 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | | 表结构 | `src/main/resources/db/audit.sql` | -| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/audit-service.yml` | +| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/audit-service.yml` | ## 修改与验证 diff --git a/audit-service/src/main/resources/application-dev.yml b/audit-service/src/main/resources/application-dev.yml deleted file mode 100644 index cd4d4a031..000000000 --- a/audit-service/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUDIT_DB_USERNAME:root} - password: ${AUDIT_DB_PASSWORD:123456} diff --git a/audit-service/src/main/resources/application-test.yml b/audit-service/src/main/resources/application-test.yml deleted file mode 100644 index 5cde9b056..000000000 --- a/audit-service/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${AUDIT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${AUDIT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml index 7246521f9..6c658ac7e 100644 --- a/audit-service/src/main/resources/application.yml +++ b/audit-service/src/main/resources/application.yml @@ -3,27 +3,27 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: audit-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - namespace: ${NACOS_NAMESPACE:public} + server-addr: ${NACOS_SERVER_ADDR} + namespace: ${NACOS_NAMESPACE} baiyue.audit.enabled: false baiyue: diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 195b1036b..2fc5501e1 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -18,7 +18,7 @@ | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 | | 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 | -| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | +| 统一会话权威校验 | `controller/InternalAccountController.java`、`service/SessionStateService.java`;内部 `/internal/auth/accounts/sessions/validate` 接收 jwtId/userId,核对 Redis Access 与状态指纹,再查询当前 Auth/IAM;内部服务令牌保护,不接收或转发用户 JWT | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | @@ -28,7 +28,9 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。独立海外网关与安全投影仍处于迁移阶段,不能将本地签发改造视为整体已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 登录、刷新和账号安全修改通过账号行锁串行化;IAM 身份、权限、客户版本、仓绑定由单条 SQL 快照组成指纹,Access/Refresh 均绑定指纹。Gateway 与业务服务逐请求在线核对,无正结果缓存;提交后开始的请求不得接受旧指纹,提交前已经完成认证的在途业务请求不追溯中断。撤销失败使变更失败或回滚,丢失撤销响应也不得恢复旧会话。 +- 校验链为 Gateway/业务服务 → Auth → IAM,不转发 Bearer、无递归。统一 `baiyue.security.session-validation-uri` 默认 `http://auth:9100/internal/auth/accounts/sessions/validate`;外层请求总超时 4 秒,Auth→IAM 连接 1 秒/读取 2 秒且不自动重试。Auth/IAM/Redis 任一不可用均失败关闭;真实吞吐与耗时仍需测试环境验证。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/auth/pom.xml b/auth/pom.xml index 8d1a1d8cb..58bb2d170 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -22,6 +22,7 @@ com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest org.springframework.securityspring-security-testtest + com.h2databaseh2test org.springframework.bootspring-boot-maven-plugin diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java new file mode 100644 index 000000000..238c63f68 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java @@ -0,0 +1,14 @@ +package com.baiyue.auth.client; + +import feign.Request; +import feign.Retryer; +import org.springframework.context.annotation.Bean; +import java.util.concurrent.TimeUnit; + +/** Only the IAM authorization client uses this bounded, single-attempt chain. */ +public class AuthorizationFeignConfiguration { + @Bean public Request.Options authorizationRequestOptions() { + return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false); + } + @Bean public Retryer authorizationRetryer() { return Retryer.NEVER_RETRY; } +} diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index a7cae146c..8b2632ae6 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -23,6 +23,14 @@ public class AuthorizationSnapshot { private Long permissionVersion; private Long overseasWarehouseId; private Long domesticWarehouseId; + private Long tenantVersion; + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, Long permissionVersion, + Long overseasWarehouseId, Long domesticWarehouseId) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L); + } public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, String clientType, Set roles, Set permissions, diff --git a/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java index 5dca79307..3a1d6a019 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader; /** * 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。 */ -@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", +@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class, url = "${baiyue.clients.iam.url:}", path = "/internal/iam") public interface IamAuthorizationClient { @PostMapping("/authorization-snapshot") diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index 966201676..922292fb6 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -3,15 +3,12 @@ package com.baiyue.auth.controller; import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AuthService; -import com.baiyue.auth.service.OverseasSessionValidationService; -import com.baiyue.auth.dto.OverseasSessionValidationDtos; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; @@ -22,14 +19,13 @@ import java.security.MessageDigest; /** 仅供可信内部服务调用的账号管理接口。 */ @RestController @RequestMapping("/internal/auth/accounts") -@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs") +@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and unified session APIs") public class InternalAccountController { @Resource private AccountManagementService service; @Resource private AuthService authService; - @Resource - private OverseasSessionValidationService overseasSessionValidationService; + @Resource private com.baiyue.auth.service.SessionStateService sessionStateService; @Value("${baiyue.security.internal-service-token}") private String configuredToken; @@ -71,15 +67,17 @@ public class InternalAccountController { @Valid @RequestBody RevokeAccountSessionsRequest request) { verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); } - @PostMapping("/overseas-session/validate") - @Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明", - description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.") - public ApiResponse validateOverseasSession( + public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId, + @jakarta.validation.constraints.NotNull Long userId) { } + + @PostMapping("/sessions/validate") + public ApiResponse validateSession( @RequestHeader(value="X-Internal-Service-Token", required=false) String token, - @Valid @RequestBody OverseasSessionValidationDtos.Request request) { + @Valid @RequestBody SessionValidationRequest request) { verify(token); - return ApiResponse.success(overseasSessionValidationService.validate(request)); + return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId())); } + @PutMapping("/{id}/profile") public ApiResponse profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { diff --git a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java deleted file mode 100644 index 2ab9a5f3f..000000000 --- a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.baiyue.auth.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */ -public final class OverseasSessionValidationDtos { - private OverseasSessionValidationDtos() { } - - @Schema(name = "OverseasSessionValidationRequest", - description = "Claims extracted only after overseas Gateway verifies the JWT signature") - public record Request( - @NotNull Long userId, - @NotNull Integer tokenVersion, - Long overseasWarehouseId) { } - - @Schema(name = "OverseasSessionValidationResponse") - public record Response(boolean valid, FailureReason failureReason) { - public static Response accepted() { return new Response(true, null); } - public static Response rejected(FailureReason reason) { return new Response(false, reason); } - } - - public enum FailureReason { - ACCOUNT_INVALID, - TOKEN_VERSION_MISMATCH, - WAREHOUSE_UNBOUND, - WAREHOUSE_MISMATCH - } -} diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java index 636a1efe0..6e61ad475 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java @@ -19,13 +19,13 @@ public class AuthAccountRepository { public AuthAccount findByUsername(String username) { return mapper.selectOne(new LambdaQueryWrapper() .eq(AuthAccount::getUsername, username) - .last("LIMIT 1")); + .last("LIMIT 1 FOR UPDATE")); } public AuthAccount findByPhone(String phone) { return mapper.selectOne(new LambdaQueryWrapper() .eq(AuthAccount::getPhone, phone) - .last("LIMIT 1")); + .last("LIMIT 1 FOR UPDATE")); } public boolean existsByUsername(String username) { @@ -53,13 +53,15 @@ public class AuthAccountRepository { public void insert(AuthAccount account) { mapper.insert(account); } public AuthAccount findById(Long id) { - return mapper.selectById(id); + return mapper.selectOne(new LambdaQueryWrapper().eq(AuthAccount::getId, id).last("FOR UPDATE")); } + public AuthAccount findCurrentById(Long id) { return mapper.selectById(id); } + public List findMerchantByTenant(Long tenantId) { return mapper.selectList(new LambdaQueryWrapper() .eq(AuthAccount::getDefaultTenantId, tenantId) - .eq(AuthAccount::getDefaultAppCode, "MERCHANT")); + .eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE")); } public void save(AuthAccount account) { diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 6c64184e1..a8877efa2 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -108,7 +108,7 @@ public class AccountManagementService { resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话", captureBefore = false, captureAfter = false) public void revokeSessions(Set accountIds) { - accountIds.forEach(accountId -> bumpVersion(required(accountId))); + accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId))); } @Transactional diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index d3b76d480..36c987ec2 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -92,7 +92,8 @@ public class AuthService { AuthAccount account = accountRepository.findById(session.userId()); if (account == null || !"ACTIVE".equals(account.getAccountStatus())) throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); - return issue(account); + if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态"); + return issue(account, session.fingerprint()); } @Transactional @@ -109,8 +110,13 @@ public class AuthService { accountRepository.save(account); } - private TokenResponse issue(AuthAccount account) { + private TokenResponse issue(AuthAccount account) { return issue(account, null); } + + private TokenResponse issue(AuthAccount account, String expectedState) { AuthorizationSnapshot authorization = authorizationLoader.load(account); + if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) { + throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录"); + } Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); @@ -145,7 +151,8 @@ public class AuthService { byte[] random = new byte[48]; secureRandom.nextBytes(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); - tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, + tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), + SessionStateService.fingerprint(account, authorization), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(), @@ -202,7 +209,7 @@ public class AuthService { .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), - authorization.getPermissions(), Duration.ofMinutes(15)); + authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15)); return new ImpersonationTokenResponse("Bearer", accessToken, 900, authorization.getRoles(), authorization.getPermissions()); } diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java deleted file mode 100644 index 0f566a290..000000000 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Service; - -import java.util.Objects; - -/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */ -@Service -public class OverseasSessionValidationService { - @Resource private AuthAccountRepository accounts; - @Resource private AuthorizationLoader authorizationLoader; - - public Response validate(Request request) { - AuthAccount account = accounts.findById(request.userId()); - if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { - return Response.rejected(FailureReason.ACCOUNT_INVALID); - } - int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion(); - if (currentVersion != request.tokenVersion()) { - return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH); - } - Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId(); - if (currentWarehouseId == null || request.overseasWarehouseId() == null) { - return Response.rejected(FailureReason.WAREHOUSE_UNBOUND); - } - if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) { - return Response.rejected(FailureReason.WAREHOUSE_MISMATCH); - } - return Response.accepted(); - } -} diff --git a/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java b/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java new file mode 100644 index 000000000..69b7480f5 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java @@ -0,0 +1,63 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.TreeSet; + +/** No cache: a validation started after a security commit must observe that commit. */ +@Service +public class SessionStateService { + private static final ObjectMapper FINGERPRINT_JSON = new ObjectMapper(); + @Resource private AuthAccountRepository accounts; + @Resource private AuthorizationLoader authorizationLoader; + @Resource private TokenSessionStore sessions; + + public static String fingerprint(AuthAccount account, AuthorizationSnapshot snapshot) { + try { + if (account.getTokenVersion() == null || snapshot.getPermissionVersion() == null + || snapshot.getTenantVersion() == null) throw new IllegalArgumentException("Missing persisted security version"); + // Ordered JSON array avoids set ordering and delimiter ambiguities. + Object[] state = {account.getId(), account.getTokenVersion(), account.getDefaultTenantId(), + account.getDefaultMembershipId(), account.getDefaultAppCode(), account.getOperatorTenantId(), + snapshot.getPermissionVersion(), snapshot.getTenantVersion(), snapshot.getClientType(), + snapshot.getOverseasWarehouseId(), snapshot.getDomesticWarehouseId(), + new TreeSet<>(snapshot.getRoles()), new TreeSet<>(snapshot.getPermissions())}; + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(FINGERPRINT_JSON.writeValueAsBytes(state))); + } catch (Exception failure) { + throw new IllegalStateException("Cannot fingerprint authorization state", failure); + } + } + + private void requireCurrent(Long userId, String expected) { + AuthAccount account = accounts.findCurrentById(userId); + if (account == null || !"ACTIVE".equals(account.getAccountStatus()) + || Integer.valueOf(1).equals(account.getDeleted()) || expected == null + || !expected.equals(fingerprint(account, authorizationLoader.load(account)))) { + throw new BizException(ResultCode.UNAUTHORIZED, "登录授权已变更,请重新登录"); + } + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW, + isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public boolean validate(String jwtId, Long userId) { + TokenSessionStore.SessionState state = sessions.loadState(jwtId); + if (state == null || !state.userId().equals(userId)) { + throw new BizException(ResultCode.UNAUTHORIZED, "登录会话已失效"); + } + requireCurrent(userId, state.fingerprint()); + return true; + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index fbfefb41b..5fb10f1b1 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -12,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.SetOperations; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; @@ -21,8 +19,6 @@ import java.security.MessageDigest; import java.time.Duration; import java.util.HexFormat; import java.util.Set; -import java.util.ArrayList; -import java.util.List; /** * 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。 @@ -42,7 +38,7 @@ public class TokenSessionStore { } public void store(String jwtId, Long userId, Set roles, Set permissions, - String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { + String fingerprint, String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String userKey = userSessionsKey(userId); @@ -50,25 +46,27 @@ public class TokenSessionStore { String accessValue = objectMapper.writeValueAsString( new AccessSession(1, userId, normalized(roles), normalized(permissions))); redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl); - String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); + storeState(jwtId, userId, fingerprint, accessTtl); + String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId, fingerprint)); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); SetOperations sets = redisTemplate.opsForSet(); - if (sets != null) sets.add(userKey, accessKey, refreshKey); + if (sets != null) sets.add(userKey, accessKey, refreshKey, stateKey(jwtId)); redisTemplate.expire(userKey, refreshTtl); } catch (Exception exception) { - try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { } + try { redisTemplate.delete(Set.of(accessKey, refreshKey, stateKey(jwtId))); } catch (DataAccessException ignored) { } throw unavailable(exception); } } public void storeAccessOnly(String jwtId, Long userId, Set roles, - Set permissions, Duration ttl) { + Set permissions, String fingerprint, Duration ttl) { try { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessValue = objectMapper.writeValueAsString( new AccessSession(1, userId, normalized(roles), normalized(permissions))); redisTemplate.opsForValue().set(accessKey, accessValue, ttl); - redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey); + storeState(jwtId, userId, fingerprint, ttl); + redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey, stateKey(jwtId)); redisTemplate.expire(userSessionsKey(userId), ttl); } catch (Exception exception) { throw unavailable(exception); @@ -103,6 +101,7 @@ public class TokenSessionStore { if (value == null || value.isBlank()) return null; RefreshSession session = objectMapper.readValue(value, RefreshSession.class); redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId())); + redisTemplate.delete(stateKey(session.accessJwtId())); return session; } catch (DataAccessException exception) { throw unavailable(exception); @@ -121,43 +120,26 @@ public class TokenSessionStore { try { Set keys = redisTemplate.opsForSet().members(userKey); if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys); - // 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。 - List legacyKeys = new ArrayList<>(); - collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false); - collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true); - if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys); redisTemplate.delete(userKey); } catch (DataAccessException exception) { throw unavailable(exception); } } - private void collectLegacyKeys(String pattern, Long userId, List result, boolean refresh) { - Cursor cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build()); - if (cursor == null) return; - try (cursor) { - while (cursor.hasNext()) { - String key = cursor.next(); - String value = redisTemplate.opsForValue().get(key); - if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) { - result.add(key); - } - } - } catch (Exception exception) { - throw unavailable(exception); - } + private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; } + + private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception { + if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state"); + redisTemplate.opsForValue().set(stateKey(jwtId), + objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl); } - private boolean belongsTo(String value, Long userId) { - if (value == null || value.isBlank()) return false; - try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); } - catch (Exception ignored) { return false; } - } - - private boolean accessBelongsTo(String value, Long userId) { - if (value == null || value.isBlank()) return false; - try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); } - catch (Exception ignored) { return false; } + public SessionState loadState(String jwtId) { + try { + if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null; + String value = redisTemplate.opsForValue().get(stateKey(jwtId)); + return value == null ? null : objectMapper.readValue(value, SessionState.class); + } catch (Exception failure) { throw unavailable(failure); } } private static Set normalized(Set values) { @@ -182,7 +164,8 @@ public class TokenSessionStore { } } - public record RefreshSession(Long userId, String accessJwtId) { } + public record SessionState(Long userId, String fingerprint) { } + public record RefreshSession(Long userId, String accessJwtId, String fingerprint) { } public record AccessSession(int version, Long userId, Set roles, Set permissions) { public AccessSession { roles = normalized(roles); diff --git a/auth/src/main/resources/application-dev.yml b/auth/src/main/resources/application-dev.yml deleted file mode 100644 index 1c0753a99..000000000 --- a/auth/src/main/resources/application-dev.yml +++ /dev/null @@ -1,10 +0,0 @@ -spring: - cloud: - nacos: - discovery: - ip: ${NACOS_DISCOVERY_IP:127.0.0.1} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUTH_DB_USERNAME:root} - password: ${AUTH_DB_PASSWORD:123456} diff --git a/auth/src/main/resources/application-test.yml b/auth/src/main/resources/application-test.yml deleted file mode 100644 index 07c7f96b5..000000000 --- a/auth/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index ad9451d45..6acb857eb 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -2,35 +2,35 @@ server: port: ${AUTH_PORT:9100} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: auth-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true data: redis: - host: ${REDIS_HOST:127.0.0.1} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} timeout: ${REDIS_TIMEOUT:3s} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue.security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} @@ -40,7 +40,7 @@ baiyue.security: lock-duration: ${LOGIN_LOCK_DURATION:15m} jwt-mode: jwks session-mode: redis - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} jwt: rsa-jwk-set: ${JWT_RSA_JWK_SET:} current-kid: ${JWT_CURRENT_KID:} @@ -50,3 +50,5 @@ baiyue.clients: url: ${IAM_SERVICE_URL:} management.endpoints.web.exposure.include: health,info,prometheus + +baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate} diff --git a/auth/src/main/resources/openapi/auth-internal.yaml b/auth/src/main/resources/openapi/auth-internal.yaml new file mode 100644 index 000000000..4972b5463 --- /dev/null +++ b/auth/src/main/resources/openapi/auth-internal.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: Auth internal session validation + version: 1.0.0 +paths: + /internal/auth/accounts/sessions/validate: + post: + summary: Validate persisted account and IAM authorization state + description: Internal network only. No Gateway public route. Verifies the Redis Access session and state fingerprint against current Auth account and a consistent IAM snapshot. No positive cache; missing state, stale state and dependency failure deny access. Does not accept or forward a user Bearer token. + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [jwtId, userId] + properties: + jwtId: {type: string, minLength: 1} + userId: {type: integer, format: int64} + responses: + '200': + description: Only string code 200 and boolean data true constitute success. Business errors deny access. + content: + application/json: + schema: + type: object + required: [code] + properties: + code: {type: string} + message: {type: string} + data: {type: boolean, nullable: true} +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token diff --git a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java deleted file mode 100644 index 7c698a724..000000000 --- a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.auth.controller; - -import com.baiyue.auth.dto.OverseasSessionValidationDtos; -import io.swagger.v3.oas.annotations.Operation; -import org.junit.jupiter.api.Test; -import org.springframework.web.bind.annotation.PostMapping; - -import java.util.Arrays; - -import static org.assertj.core.api.Assertions.assertThat; - -class OverseasSessionValidationControllerContractTest { - @Test - void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() { - var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods()) - .filter(candidate -> candidate.getName().equals("validateOverseasSession")) - .findFirst().orElseThrow(); - - assertThat(method.getAnnotation(PostMapping.class).value()) - .containsExactly("/overseas-session/validate"); - assertThat(method.getAnnotation(Operation.class).description()) - .contains("Gateway JWT verification", "raw tokens and user credentials are forbidden"); - assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents()) - .extracting(java.lang.reflect.RecordComponent::getName) - .containsExactly("userId", "tokenVersion", "overseasWarehouseId") - .doesNotContain("token", "accessToken", "authorization", "password"); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index f908f1ab3..d8f367e62 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -93,7 +93,7 @@ class AuthServiceSmallJwtTest { assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current"); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), - org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), + org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); @@ -156,7 +156,7 @@ class AuthServiceSmallJwtTest { Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(tokenSessionStore.consume("refresh-old")).thenReturn( - new TokenSessionStore.RefreshSession(52L, "refresh-session")); + new TokenSessionStore.RefreshSession(52L, "refresh-session", SessionStateService.fingerprint(account, snapshot))); when(repository.findById(52L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); @@ -175,7 +175,7 @@ class AuthServiceSmallJwtTest { verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), - org.mockito.ArgumentMatchers.eq(response.refreshToken()), + anyString(), org.mockito.ArgumentMatchers.eq(response.refreshToken()), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); verify(authorizationLoader).load(account); @@ -231,6 +231,6 @@ class AuthServiceSmallJwtTest { verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), - org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); + anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); } } diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java deleted file mode 100644 index c076584bf..000000000 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class OverseasSessionValidationServiceTest { - private final AuthAccountRepository accounts = mock(AuthAccountRepository.class); - private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class); - private final OverseasSessionValidationService service = new OverseasSessionValidationService(); - private AuthAccount account; - - @BeforeEach - void setUp() throws Exception { - inject("accounts", accounts); - inject("authorizationLoader", authorizationLoader); - account = new AuthAccount(); - account.setId(42L); - account.setAccountStatus("ACTIVE"); - account.setTokenVersion(8); - when(accounts.findById(42L)).thenReturn(account); - } - - @Test - void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() { - assertThat(service.validate(new Request(42L, 7, 900L))) - .extracting("valid", "failureReason") - .containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH); - } - - @Test - void newlyIssuedTokenWithCurrentWarehousePasses() { - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); - - assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue(); - } - - @Test - void missingOrChangedWarehouseBindingFailsClosed() { - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null)); - assertThat(service.validate(new Request(42L, 8, null)).failureReason()) - .isEqualTo(FailureReason.WAREHOUSE_UNBOUND); - - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); - assertThat(service.validate(new Request(42L, 8, 900L)).failureReason()) - .isEqualTo(FailureReason.WAREHOUSE_MISMATCH); - } - - @Test - void disabledAccountFailsClosedBeforeIamLookup() { - account.setAccountStatus("DISABLED"); - assertThat(service.validate(new Request(42L, 8, 901L)).failureReason()) - .isEqualTo(FailureReason.ACCOUNT_INVALID); - } - - private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { - return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId); - } - - private void inject(String fieldName, Object value) throws Exception { - var field = OverseasSessionValidationService.class.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(service, value); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java b/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java new file mode 100644 index 000000000..6f1af9625 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java @@ -0,0 +1,281 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.AuthSecurityProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; +import com.baiyue.auth.dto.AccountManagementDtos.ResetPasswordRequest; +import com.baiyue.auth.dto.LoginRequest; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real MyBatis repository, JDBC transactions/row locks and production login/refresh/revocation services. + * Redis is an atomic in-memory transport double; this is not a Redis/MySQL environment test. */ +class SessionStateConcurrencyTest { + JdbcTemplate authDb, iamDb; + DataSourceTransactionManager authTx, iamTx; + AuthService auth; + AccountManagementService accounts; + SessionStateService states; + TokenSessionStore sessions; + JwtEncoder encoder; + AtomicBoolean redisFailure; + final ConcurrentMap values = new ConcurrentHashMap<>(); + final ConcurrentMap> sets = new ConcurrentHashMap<>(); + + @BeforeEach void setup() throws Exception { + authDb = database(); iamDb = database(); + authTx = new DataSourceTransactionManager(authDb.getDataSource()); + iamTx = new DataSourceTransactionManager(iamDb.getDataSource()); + authDb.execute("CREATE TABLE auth_account(id BIGINT PRIMARY KEY, username VARCHAR(50),phone VARCHAR(50)," + + "password_hash VARCHAR(100),display_name VARCHAR(50),account_status VARCHAR(20),failed_login_count INT," + + "locked_until TIMESTAMP,token_version INT,password_change_required BOOLEAN,default_tenant_id BIGINT," + + "operator_tenant_id BIGINT,default_membership_id BIGINT,default_app_code VARCHAR(20),deleted INT," + + "last_login_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + authDb.update("INSERT INTO auth_account(id,username,password_hash,account_status,token_version,default_tenant_id," + + "operator_tenant_id,default_membership_id,default_app_code,deleted) VALUES(1,'operator','old-password','ACTIVE',1,7,7,9,'MERCHANT',0)"); + iamDb.execute("CREATE TABLE authorization_state(id BIGINT PRIMARY KEY, version BIGINT, tenant_version BIGINT, permission VARCHAR(50), warehouse BIGINT, active BOOLEAN)"); + iamDb.update("INSERT INTO authorization_state VALUES (1,1,1,'write',10,true)"); + + var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean(); + factory.setDataSource(authDb.getDataSource()); + var config = new com.baomidou.mybatisplus.core.MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(com.baiyue.auth.mapper.AuthAccountMapper.class); + factory.setConfiguration(config); + var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject()) + .getMapper(com.baiyue.auth.mapper.AuthAccountMapper.class); + var repository = new AuthAccountRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + AuthorizationLoader loader = mock(AuthorizationLoader.class); + when(loader.load(any())).thenAnswer(call -> { + var tx = new TransactionTemplate(iamTx); + tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ); + return tx.execute(status -> iamDb.queryForObject("SELECT * FROM authorization_state WHERE id=1", (rs, row) -> { + if (!rs.getBoolean("active")) throw new IllegalArgumentException("IAM identity disabled"); + return new AuthorizationSnapshot(1L, 7L, 9L, "MERCHANT", "MERCHANT", Set.of("OPERATOR"), + Set.of(rs.getString("permission")), rs.getLong("version"), rs.getLong("warehouse"), null, + rs.getLong("tenant_version")); + })); + }); + sessions = new TokenSessionStore(); + ReflectionTestUtils.setField(sessions, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(sessions, "keyPrefix", "test"); + ReflectionTestUtils.setField(sessions, "redisTemplate", redis()); + PasswordEncoder passwords = mock(PasswordEncoder.class); + when(passwords.matches(anyString(), anyString())).thenAnswer(c -> c.getArgument(0).equals(c.getArgument(1))); + when(passwords.encode(anyString())).thenAnswer(c -> c.getArgument(0)); + encoder = mock(JwtEncoder.class); + when(encoder.encode(any())).thenAnswer(c -> { + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }); + var service = new AuthService(); + ReflectionTestUtils.setField(service, "accountRepository", repository); + ReflectionTestUtils.setField(service, "authorizationLoader", loader); + ReflectionTestUtils.setField(service, "tokenSessionStore", sessions); + ReflectionTestUtils.setField(service, "passwordEncoder", passwords); + ReflectionTestUtils.setField(service, "jwtEncoder", encoder); + ReflectionTestUtils.setField(service, "properties", new AuthSecurityProperties("issuer", "aud", Duration.ofMinutes(5), Duration.ofHours(1), 5, Duration.ofMinutes(1))); + ReflectionTestUtils.setField(service, "signingProperties", new UnifiedJwtProperties("unused", "kid")); + auth = transactional(service); + var management = new AccountManagementService(); + ReflectionTestUtils.setField(management, "repository", repository); + ReflectionTestUtils.setField(management, "tokenSessionStore", sessions); + ReflectionTestUtils.setField(management, "passwordEncoder", passwords); + accounts = transactional(management); + var state = new SessionStateService(); + ReflectionTestUtils.setField(state, "accounts", repository); + ReflectionTestUtils.setField(state, "authorizationLoader", loader); + ReflectionTestUtils.setField(state, "sessions", sessions); + states = transactional(state); + } + + @Test void lateLoginAfterRevokeBeforeIamCommitCannotUseOldPermissionsOrRefresh() throws Exception { + var revoked = new CountDownLatch(1); + var snapshotLoaded = new CountDownLatch(1); + var committed = new CountDownLatch(1); + doAnswer(c -> { + snapshotLoaded.countDown(); await(committed); + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }).when(encoder).encode(any()); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future change = pool.submit(() -> { + new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + revoked.countDown(); await(snapshotLoaded); + }); + committed.countDown(); + }); + await(revoked); + var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password"))); + var stale = login.get(5, TimeUnit.SECONDS); change.get(5, TimeUnit.SECONDS); + // Access and state were written AFTER revoke, yet the first post-commit request is denied. + assertThat(values).containsKey("test:access:" + stale.accessToken()); + assertThatThrownBy(() -> states.validate(stale.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(fresh.accessToken(), 1L)).isTrue(); + assertThat(fresh.authorities()).containsExactly("read"); + assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class); + } finally { committed.countDown(); pool.shutdownNow(); } + } + + @Test void passwordResetWaitsForConcurrentLoginThenInvalidatesAccessAndRefresh() throws Exception { + var loaded = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(c -> { + loaded.countDown(); await(release); + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }).when(encoder).encode(any()); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password"))); + await(loaded); + var resetting = new CountDownLatch(1); + var reset = pool.submit(() -> { resetting.countDown(); accounts.resetPassword(1L, new ResetPasswordRequest("new-password", false), false); }); + await(resetting); + assertThatThrownBy(() -> reset.get(100, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + release.countDown(); + var old = login.get(5, TimeUnit.SECONDS); reset.get(5, TimeUnit.SECONDS); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class); + } finally { release.countDown(); pool.shutdownNow(); } + } + + @Test void failedRevocationRollsBackAuthAndIamAndDoesNotReportSuccess() { + var old = auth.login(new LoginRequest("operator", "old-password")); + redisFailure.set(true); + assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, active=false WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + })).isInstanceOf(BizException.class); + redisFailure.set(false); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(1); + assertThat(iamDb.queryForObject("SELECT version FROM authorization_state WHERE id=1", Long.class)).isEqualTo(1); + assertThat(states.validate(old.accessToken(), 1L)).isTrue(); + } + + @Test void committedIamVersionRejectsResidualSessionWhenNoRedisDeletionHappens() { + var old = auth.login(new LoginRequest("operator", "old-password")); + new TransactionTemplate(iamTx).executeWithoutResult(tx -> + iamDb.update("UPDATE authorization_state SET tenant_version=tenant_version+1 WHERE id=1")); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class); + var current = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(current.accessToken(), 1L)).isTrue(); + iamDb.update("UPDATE authorization_state SET warehouse=20 WHERE id=1"); + assertThatThrownBy(() -> states.validate(current.accessToken(), 1L)).isInstanceOf(BizException.class); + } + + @Test void iamRollbackPreservesFingerprintAndOtherUserCannotUseSession() { + var old = auth.login(new LoginRequest("operator", "old-password")); + new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + tx.setRollbackOnly(); + }); + assertThat(states.validate(old.accessToken(), 1L)).isTrue(); + assertThatThrownBy(() -> states.validate(old.accessToken(), 2L)).isInstanceOf(BizException.class); + var refreshed = auth.refresh(old.refreshToken()); + assertThat(states.validate(refreshed.accessToken(), 1L)).isTrue(); + } + + @Test void lostRevokeAcknowledgementRollsBackIamButCannotRestoreOldAuthSession() { + var old = auth.login(new LoginRequest("operator", "old-password")); + assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + throw new IllegalStateException("Auth response timed out after its commit"); + })).isInstanceOf(IllegalStateException.class); + assertThat(iamDb.queryForObject("SELECT permission FROM authorization_state WHERE id=1", String.class)).isEqualTo("write"); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(fresh.accessToken(), 1L)).isTrue(); + assertThat(fresh.authorities()).containsExactly("write"); + } + + @Test void disabledCustomerOrMemberAndUnavailableIamRejectAccessAndRefresh() { + var old = auth.login(new LoginRequest("operator", "old-password")); + // No Redis deletion: the authoritative identity gate alone closes the session. + iamDb.update("UPDATE authorization_state SET active=false, tenant_version=tenant_version+1 WHERE id=1"); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(IllegalArgumentException.class); + iamDb.update("UPDATE authorization_state SET active=true WHERE id=1"); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + iamDb.execute("DROP TABLE authorization_state"); + assertThatThrownBy(() -> states.validate(fresh.accessToken(), 1L)).isInstanceOf(org.springframework.dao.DataAccessException.class); + assertThatThrownBy(() -> auth.refresh(fresh.refreshToken())).isInstanceOf(org.springframework.dao.DataAccessException.class); + } + + @SuppressWarnings("unchecked") private StringRedisTemplate redis() { + redisFailure = new AtomicBoolean(); + var redis = mock(StringRedisTemplate.class); + ValueOperations ops = mock(ValueOperations.class); + SetOperations index = mock(SetOperations.class); + when(redis.opsForValue()).thenReturn(ops); when(redis.opsForSet()).thenReturn(index); + doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(1)); return null; }).when(ops).set(anyString(), anyString(), any(Duration.class)); + when(ops.get(anyString())).thenAnswer(c -> values.get(c.getArgument(0))); + when(ops.getAndDelete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0))); + when(redis.hasKey(anyString())).thenAnswer(c -> values.containsKey(c.getArgument(0))); + when(redis.delete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)) != null); + when(redis.delete(anyCollection())).thenAnswer(c -> { + if (redisFailure.get()) throw new org.springframework.data.redis.RedisConnectionFailureException("offline"); + long count = 0; for (Object key : (java.util.Collection) c.getArgument(0)) if (values.remove(key) != null) count++; + return count; + }); + when(index.members(anyString())).thenAnswer(c -> sets.getOrDefault(c.getArgument(0), Set.of())); + doAnswer(c -> { + Set members = sets.computeIfAbsent(c.getArgument(0), key -> ConcurrentHashMap.newKeySet()); + for (int i=1; i T transactional(T target) { + var factory = new ProxyFactory(target); factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(authTx, new AnnotationTransactionAttributeSource())); + return (T) factory.getProxy(); + } + static void await(CountDownLatch latch) { + try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Barrier timed out"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); } + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java index a18fc513d..760896079 100644 --- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -47,7 +47,7 @@ class TokenSessionStoreTest { when(redis.opsForValue()).thenReturn(values); store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"), - "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); + "state", "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); verify(values).set(eq("baiyue:auth:access:jti-1"), org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1") @@ -64,7 +64,7 @@ class TokenSessionStoreTest { ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); String json = new ObjectMapper().writeValueAsString( - new TokenSessionStore.RefreshSession(42L, "jti-1")); + new TokenSessionStore.RefreshSession(42L, "jti-1", "state")); when(values.getAndDelete(anyString())).thenReturn(json); TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 98ad33565..564b68cfd 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -15,6 +15,7 @@ | 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | | MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | | 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 | +| 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 | | 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` | ## 修改与验证 diff --git a/common-core/pom.xml b/common-core/pom.xml index faff84fd8..9aef71cb6 100644 --- a/common-core/pom.xml +++ b/common-core/pom.xml @@ -24,5 +24,7 @@ spring-boot-starter-test test + org.springframeworkspring-jdbc + com.h2databaseh2test diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java new file mode 100644 index 000000000..c5b00f76b --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java @@ -0,0 +1,59 @@ +package com.baiyue.common.core.outbox; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Set; +import java.util.function.BooleanSupplier; +import java.util.function.IntSupplier; + +/** Same-database audit. HTTP must only run after begin returns; never hold a DB transaction over HTTP. */ +public final class EventDeliveryJournal { + public enum Outcome { APPLIED, DUPLICATE, STALE, RETRY, DEAD, LEASE_EXPIRED } + private final JdbcTemplate jdbc; + private final TransactionTemplate transaction; + + public EventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { + this.jdbc = Objects.requireNonNull(jdbc); + if (!(manager instanceof DataSourceTransactionManager local) + || local.getDataSource() != jdbc.getDataSource()) { + throw new IllegalArgumentException("Delivery audit must share the local Outbox transaction manager"); + } + transaction = new TransactionTemplate(manager); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public boolean begin(String attempt, String service, String eventType, String entryId, + String eventId, String target, LocalDateTime now, BooleanSupplier claim) { + return Boolean.TRUE.equals(transaction.execute(status -> { + if (!claim.getAsBoolean()) return false; + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES(?,?,?,?,?,?,?,'STARTED')", + attempt, service, eventType, entryId, eventId, target, now); + return true; + })); + } + + public boolean complete(String attempt, Outcome outcome, LocalDateTime now, + String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) { + if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode) + && !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code"); + return Boolean.TRUE.equals(transaction.execute(status -> { + int changed = updateOwnedOutbox.getAsInt(); + if (changed == 0) return false; // A stale worker has no right to change either record. + if (changed != 1) throw new IllegalStateException("Unexpected Outbox update count"); + LocalDateTime started = jdbc.queryForObject( + "SELECT started_at FROM event_delivery_attempt WHERE attempt_id=? AND outcome='STARTED' FOR UPDATE", + LocalDateTime.class, attempt); + long duration = Math.max(0, Duration.between(Objects.requireNonNull(started), now).toMillis()); + int audited = jdbc.update("UPDATE event_delivery_attempt SET finished_at=?,duration_ms=?,outcome=?,error_code=?,next_retry_at=? WHERE attempt_id=? AND outcome='STARTED'", + now, duration, outcome.name(), errorCode, nextRetry, attempt); + if (audited != 1) throw new IllegalStateException("Delivery completion audit is missing"); + return true; + })); + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java new file mode 100644 index 000000000..32dc2a7be --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java @@ -0,0 +1,4 @@ +package com.baiyue.common.core.outbox; + +/** Marks services that require durable attempt audit and cached delivery metrics. */ +public interface EventDeliveryProducer { } diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java new file mode 100644 index 000000000..23d1b8934 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.common.core.outbox; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Dedicated operations credential; a missing or weak configuration disables this capability. */ +public final class EventOperationsTokenGuard { + private final byte[] expected; + public EventOperationsTokenGuard(String token) { + expected = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + } + public void require(String supplied) { + if (expected.length < 32 || supplied == null || supplied.length() > 4096 + || !MessageDigest.isEqual(expected, supplied.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "事件运维凭据无效或入口未启用"); + } + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java new file mode 100644 index 000000000..cdeaf1f48 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java @@ -0,0 +1,129 @@ +package com.baiyue.common.core.outbox; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; + +/** Transactional operator retry of an immutable, service-owned Outbox entry. No cross-schema access. */ +public final class OutboxOperations { + public record Layout(String table, String idColumn, String statusColumn, Set eventTypes) { + public Layout { + for (String name : List.of(table, idColumn, statusColumn)) { + if (!name.matches("[a-z][a-z0-9_]*")) throw new IllegalArgumentException("Invalid Outbox identifier"); + } + eventTypes = Set.copyOf(eventTypes); + if (eventTypes.isEmpty()) throw new IllegalArgumentException("Event allowlist is required"); + } + } + public record Identity(String eventId, String eventType, String payloadHash) { } + public record RetryRequest(String requestId, String operator, String reason, String expectedPayloadHash) { } + public record RetryResult(String entryId, String eventId, String payloadHash, String requestId, boolean duplicate) { } + public record Entry(String entryId, String eventId, String eventType, String payloadHash, + String status, int failures, String lastError, Object nextRetryAt) { } + private final JdbcTemplate jdbc; + private final Layout layout; + private final Function identityReader; + private final TransactionTemplate transaction; + + public OutboxOperations(JdbcTemplate jdbc, PlatformTransactionManager transactions, Layout layout, + Function identityReader) { + this.jdbc = jdbc; + this.layout = layout; + this.identityReader = identityReader; + this.transaction = new TransactionTemplate(transactions); + } + + public Entry inspect(String entryId) { + validateId(entryId); + Map row = find(entryId, false); + Identity identity = identity(row); + return new Entry(String.valueOf(row.get("entry_id")), identity.eventId(), identity.eventType(), identity.payloadHash(), + String.valueOf(row.get("entry_status")), ((Number) row.get("retry_count")).intValue(), + (String) row.get("last_error"), row.get("next_retry_at")); + } + + public RetryResult retry(String entryId, RetryRequest request) { + validateId(entryId); + validateRequest(request); + try { + return transaction.execute(status -> { + Map row = find(entryId, true); + String canonicalId = String.valueOf(row.get("entry_id")); + Identity identity = identity(row); + if (!identity.payloadHash().equalsIgnoreCase(request.expectedPayloadHash())) { + throw conflict("事件摘要已变化,拒绝重试"); + } + List> prior = jdbc.queryForList( + "SELECT producer_table,entry_id,event_id,payload_hash,operator_name,reason FROM event_delivery_retry_audit WHERE request_id=?", + request.requestId()); + if (!prior.isEmpty()) { + Map audit = prior.get(0); + if (!layout.table().equals(audit.get("producer_table")) || !canonicalId.equals(audit.get("entry_id")) + || !identity.eventId().equals(audit.get("event_id")) + || !identity.payloadHash().equals(audit.get("payload_hash")) + || !request.operator().equals(audit.get("operator_name")) || !request.reason().equals(audit.get("reason"))) { + throw conflict("操作请求编号已用于不同重试参数"); + } + return result(canonicalId, identity, request, true); + } + if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件"); + LocalDateTime now = LocalDateTime.now(); + int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn() + + "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE " + + layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId); + if (updated != 1) throw conflict("事件状态已变化"); + jdbc.update("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + request.requestId(), layout.table(), canonicalId, identity.eventId(), identity.eventType(), identity.payloadHash(), + request.operator(), request.reason(), row.get("retry_count"), row.get("last_error"), now); + return result(canonicalId, identity, request, false); + }); + } catch (DuplicateKeyException duplicate) { + throw conflict("操作请求编号冲突,原事务已回滚"); + } + } + + private Map find(String id, boolean lock) { + List> rows = jdbc.queryForList("SELECT " + layout.idColumn() + " AS entry_id,payload_json," + layout.statusColumn() + + " AS entry_status,retry_count,last_error,next_retry_at FROM " + layout.table() + " WHERE " + + layout.idColumn() + "=?" + (lock ? " FOR UPDATE" : ""), id); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "事件不存在"); + return rows.get(0); + } + private Identity identity(Map row) { + Identity identity = identityReader.apply(String.valueOf(row.get("payload_json"))); + if (!layout.eventTypes().contains(identity.eventType())) throw new BizException(ResultCode.FORBIDDEN, "事件不属于本运维入口"); + return identity; + } + private static RetryResult result(String entryId, Identity identity, RetryRequest request, boolean duplicate) { + return new RetryResult(entryId, identity.eventId(), identity.payloadHash(), request.requestId(), duplicate); + } + private static void validateId(String entryId) { + if (entryId == null || !entryId.matches("[A-Za-z0-9-]{1,64}")) throw new BizException(ResultCode.BAD_REQUEST, "事件记录标识无效"); + } + private static void validateRequest(RetryRequest request) { + if (request == null) throw new BizException(ResultCode.BAD_REQUEST, "重试参数不能为空"); + try { + if (!UUID.fromString(request.requestId()).toString().equalsIgnoreCase(request.requestId())) throw new IllegalArgumentException(); + } catch (RuntimeException failure) { throw new BizException(ResultCode.BAD_REQUEST, "requestId 必须为标准 UUID"); } + requireText(request.operator(), 128, "操作人"); + requireText(request.reason(), 500, "原因"); + if (request.expectedPayloadHash() == null || !request.expectedPayloadHash().matches("sha256:[0-9a-fA-F]{64}")) { + throw new BizException(ResultCode.BAD_REQUEST, "必须提供预期事件摘要"); + } + } + private static void requireText(String value, int length, String label) { + if (value == null || value.isBlank() || value.length() > length || value.chars().anyMatch(Character::isISOControl)) { + throw new BizException(ResultCode.BAD_REQUEST, label + "无效"); + } + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java b/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java new file mode 100644 index 000000000..48b4f0083 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java @@ -0,0 +1,83 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryJournalTest { + JdbcTemplate jdbc; + EventDeliveryJournal journal; + DataSourceTransactionManager transactions; + @BeforeEach void setup() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + transactions = new DataSourceTransactionManager(source); + journal = new EventDeliveryJournal(jdbc, transactions); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + jdbc.execute("CREATE TABLE work(id INT PRIMARY KEY,state VARCHAR(20),owner VARCHAR(36))"); + jdbc.update("INSERT INTO work VALUES(1,'PENDING',NULL)"); + } + boolean begin(String owner) { + return journal.begin(owner, "firstleg", "FirstLegLoadingPlanCompleted", "1", UUID.randomUUID().toString(), "lastleg-events", LocalDateTime.now(), + () -> jdbc.update("UPDATE work SET state='PROCESSING',owner=? WHERE id=1 AND state='PENDING'", owner) == 1); + } + int finish(String owner) { return jdbc.update("UPDATE work SET state='PUBLISHED',owner=NULL WHERE id=1 AND owner=?", owner); } + String state() { return jdbc.queryForObject("SELECT state FROM work WHERE id=1", String.class); } + String outcome(String attempt) { return jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, attempt); } + + @Test void startedIsCommittedBeforeCallerContinuesEvenWithinAnOuterTransaction() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + assertThat(begin("a")).isTrue(); + // Another connection sees STARTED before any HTTP would be invoked. + var other = new JdbcTemplate(jdbc.getDataSource()); + assertThat(other.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + status.setRollbackOnly(); + }); + assertThat(state()).isEqualTo("PROCESSING"); + assertThat(outcome("a")).isEqualTo("STARTED"); // process interruption leaves diagnostic evidence + assertThat(begin("b")).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_attempt", Integer.class)).isEqualTo(1); + } + @Test void startAuditFailureRollsBackTheClaim() { + assertThat(begin("a")).isTrue(); + jdbc.update("UPDATE work SET state='PENDING',owner=NULL"); + assertThatThrownBy(() -> begin("a")).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PENDING"); + } + @Test void completionAuditFailureRollsBackOutboxAndPreservesStartedRecord() { + begin("a"); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PROCESSING"); + assertThat(outcome("a")).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isTrue(); + assertThat(state()).isEqualTo("PUBLISHED"); + assertThat(outcome("a")).isEqualTo("APPLIED"); + assertThat(jdbc.queryForObject("SELECT duration_ms FROM event_delivery_attempt", Long.class)).isNotNegative(); + } + @Test void recoveryAndLateResultCannotOverwriteTheNewOwnerOrItsAudit() { + begin("a"); + var retryAt = LocalDateTime.now().plusSeconds(5); + assertThat(journal.complete("a", Outcome.LEASE_EXPIRED, LocalDateTime.now(), "LEASE_EXPIRED", retryAt, + () -> jdbc.update("UPDATE work SET state='PENDING',owner=NULL WHERE owner='a'"))).isTrue(); + begin("b"); + assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isFalse(); + assertThat(outcome("a")).isEqualTo("LEASE_EXPIRED"); + assertThat(outcome("b")).isEqualTo("STARTED"); + assertThat(state()).isEqualTo("PROCESSING"); + } + @Test void unexpectedCompletionWithoutStartedRecordRollsBackInsteadOfUsingAFallback() { + jdbc.update("UPDATE work SET state='PROCESSING',owner='missing'"); + assertThatThrownBy(() -> journal.complete("missing", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("missing"))).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PROCESSING"); + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java b/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java new file mode 100644 index 000000000..688006950 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java @@ -0,0 +1,23 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class EventOperationsTokenGuardTest { + @Test void missingOrShortConfigurationDisablesOperations() { + for (String configured : new String[] {null, "", "short"}) { + var guard = new EventOperationsTokenGuard(configured); + assertThatThrownBy(() -> guard.require(configured)).isInstanceOf(BizException.class); + } + } + @Test void onlyExactCredentialIsAccepted() { + String token = "operation-secret-".repeat(3); + var guard = new EventOperationsTokenGuard(token); + assertThatCode(() -> guard.require(token)).doesNotThrowAnyException(); + for (String supplied : new String[] {null, "", token + " ", token.toUpperCase(), "a".repeat(4097)}) { + assertThatThrownBy(() -> guard.require(supplied)).isInstanceOf(BizException.class); + } + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java new file mode 100644 index 000000000..43096352d --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java @@ -0,0 +1,88 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.*; + +class OutboxOperationsTest { + private static final String HASH = "sha256:" + "a".repeat(64); + private JdbcTemplate jdbc; + private OutboxOperations operations; + + @BeforeEach void setup() { + var source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE events(id VARCHAR(64) PRIMARY KEY,payload_json VARCHAR(200),status VARCHAR(20),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(100),locked_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)"); + operations = new OutboxOperations(jdbc, new DataSourceTransactionManager(source), + new OutboxOperations.Layout("events", "id", "status", Set.of("business.completed")), + payload -> new OutboxOperations.Identity(payload, "business.completed", HASH)); + jdbc.update("INSERT INTO events VALUES('1','event-1','DEAD',12,'timeout',CURRENT_TIMESTAMP,'old-worker',CURRENT_TIMESTAMP)"); + } + private OutboxOperations.RetryRequest request() { + return new OutboxOperations.RetryRequest(UUID.randomUUID().toString(), "operator-7", "dependency restored", HASH); + } + + @Test void retryPreservesPayloadAndAuditsPreviousFailureThenDeduplicates() { + var request = request(); + assertThat(operations.retry("1", request).duplicate()).isFalse(); + assertThat(operations.retry("1", request).duplicate()).isTrue(); + assertThat(jdbc.queryForMap("SELECT * FROM events WHERE id='1'")) + .containsEntry("PAYLOAD_JSON", "event-1").containsEntry("STATUS", "PENDING") + .containsEntry("RETRY_COUNT", 0).containsEntry("LOCKED_BY", null).containsEntry("LAST_ERROR", null); + assertThat(jdbc.queryForMap("SELECT * FROM event_delivery_retry_audit")) + .containsEntry("PREVIOUS_FAILURES", 12).containsEntry("PREVIOUS_ERROR", "timeout") + .containsEntry("OPERATOR_NAME", "operator-7").containsEntry("PAYLOAD_HASH", HASH); + } + + @Test void auditInsertFailureRollsBackRequeue() { + jdbc.execute("ALTER TABLE event_delivery_retry_audit ADD CONSTRAINT reject_audit CHECK(previous_failures < 0)"); + assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(operations.inspect("1").status()).isEqualTo("DEAD"); + assertThat(operations.inspect("1").failures()).isEqualTo(12); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero(); + } + + @Test void staleHashAndNonDeadAndReusedRequestAreRejected() { + var original = request(); + var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64)); + assertThatThrownBy(() -> operations.retry("1", stale)).isInstanceOf(BizException.class); + operations.retry("1", original); + assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(BizException.class); + var changed = new OutboxOperations.RetryRequest(original.requestId(), "someone-else", original.reason(), HASH); + assertThatThrownBy(() -> operations.retry("1", changed)).isInstanceOf(BizException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + } + + @Test void concurrentSameRequestHasOneAuditAndOneDuplicate() throws Exception { + var request = request(); + var start = new CountDownLatch(1); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> { start.await(); return operations.retry("1", request); }); + var second = pool.submit(() -> { start.await(); return operations.retry("1", request); }); + start.countDown(); + assertThat(first.get(10, TimeUnit.SECONDS).duplicate()).isNotEqualTo(second.get(10, TimeUnit.SECONDS).duplicate()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + } finally { pool.shutdownNow(); } + } + + @Test void crossEntryRequestCollisionRollsBackSecondEntry() { + jdbc.update("INSERT INTO events VALUES('2','event-2','DEAD',12,'timeout',CURRENT_TIMESTAMP,NULL,NULL)"); + var request = request(); + operations.retry("1", request); + assertThatThrownBy(() -> operations.retry("2", request)).isInstanceOf(BizException.class); + assertThat(operations.inspect("2").status()).isEqualTo("DEAD"); + } +} diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index c1ce19dbe..6a8721622 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -20,7 +20,7 @@ | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 | | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | -| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | +| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | @@ -29,7 +29,7 @@ ## 修改与验证 -- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 +- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 - `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java index d4cc2775b..b4d91c3d3 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; /** - * 跨服务和跨区域传输使用的不可变事件信封。 + * 统一测试环境中跨业务服务传输使用的不可变事件信封。 * *

租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。 * {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。

@@ -14,8 +14,6 @@ import java.time.Instant; * @param eventType 事件类型,不包含版本号 * @param eventVersion 事件契约版本,从 1 开始 * @param occurredAt 业务事实实际发生时间,使用 UTC 时间点 - * @param sourceRegion 事件来源业务单元 - * @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同 * @param tenantId 事件所属客户租户 ID * @param aggregateType 聚合类型 * @param aggregateId 聚合 ID @@ -32,8 +30,6 @@ public record EventEnvelope( String eventType, int eventVersion, Instant occurredAt, - RegionCode sourceRegion, - RegionCode targetRegion, String tenantId, String aggregateType, String aggregateId, diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java index 6d09aae10..f0ca66922 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java @@ -37,12 +37,6 @@ public final class EventEnvelopeValidator { if (envelope.occurredAt() == null) { errors.add("occurredAt 不能为空"); } - if (envelope.sourceRegion() == null) { - errors.add("sourceRegion 不能为空"); - } - if (envelope.targetRegion() == null) { - errors.add("targetRegion 不能为空"); - } requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH); if (envelope.aggregateType() == null || !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) { diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java b/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java deleted file mode 100644 index f70c608c2..000000000 --- a/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.baiyue.common.event; - -/** - * 事件来源与目标部署区域。 - * - *

区域表示独立业务单元,不表示用户当前所在地。

- */ -public enum RegionCode { - /** 国内业务单元。 */ - CN, - /** 海外业务单元。 */ - OVERSEAS -} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java index e21ce9ed2..69ac6b3b2 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -8,8 +8,7 @@ import static org.assertj.core.api.Assertions.*; class DeliveryAckTest { private EventEnvelope event(String type) { - return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), - RegionCode.CN, RegionCode.OVERSEAS, "0", "TEST", "1", 1, null, + return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), "0", "TEST", "1", 1, null, null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64)); } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java index 569608b99..b2d115827 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java @@ -13,7 +13,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@DisplayName("跨区域事件信封契约") +@DisplayName("跨服务事件信封契约") class EventEnvelopeTest { private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @@ -26,6 +26,8 @@ class EventEnvelopeTest { EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class); assertThat(restored).isEqualTo(source); + assertThat(objectMapper.readTree(json).has("sourceRegion")).isFalse(); + assertThat(objectMapper.readTree(json).has("targetRegion")).isFalse(); assertThat(objectMapper.readTree(json).path("eventType").asText()) .isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED); } @@ -40,7 +42,7 @@ class EventEnvelopeTest { @Test void validatorReturnsAllBasicErrorsAtOnce() { EventEnvelope envelope = new EventEnvelope( - "bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ", + "bad-id", "bad_type", 0, null, " ", "bad-type", " ", -1, 0L, " ", " ", "bad-cause", null, "bad-hash"); List errors = EventEnvelopeValidator.validate(envelope); @@ -90,8 +92,6 @@ class EventEnvelopeTest { EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, 1, Instant.parse("2026-07-29T08:30:00Z"), - RegionCode.CN, - RegionCode.OVERSEAS, "20001", "FIRST_LEG_SHIPMENT", "90001", diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index db8bb210f..cf49042bd 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -13,7 +13,7 @@ | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | -| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java`;`HttpSessionStateVerifier.java` 每请求调用 Auth 权威状态校验,超时/错误失败关闭 | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` | @@ -24,5 +24,6 @@ - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 - `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 -- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 +- Access 先校验 Redis 会话身份,再通过 `baiyue.security.session-validation-uri` 在线核对 Auth/IAM 持久化状态;禁止正结果缓存、失败降级或请求头信任。内部调用只带服务令牌和 jwtId/userId,不透传 Bearer;4 秒总超时、1 秒连接超时、不跟随重定向。 +- 不降低服务端客户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index 25fe60b58..e6eef8e8d 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -50,13 +50,16 @@ public class BaiyueSecurityAutoConfiguration { org.springframework.beans.factory.ObjectProvider redis, ObjectMapper objectMapper, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, - @Value("${baiyue.security.session-mode:redis}") String sessionMode) { + @Value("${baiyue.security.session-mode:redis}") String sessionMode, + @Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") String validationUri, + @Value("${baiyue.security.internal-service-token}") String internalToken) { if (!"redis".equalsIgnoreCase(sessionMode)) { throw new IllegalStateException("Only shared Redis access sessions are supported"); } StringRedisTemplate template = redis.getIfAvailable(); if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate"); - AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper); + AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper, + new HttpSessionStateVerifier(validationUri, internalToken, objectMapper)); return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); } diff --git a/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java b/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java new file mode 100644 index 000000000..c9f4f864f --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java @@ -0,0 +1,44 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; + +/** Bounded, non-redirecting internal call. Never forwards an end-user Authorization header. */ +public final class HttpSessionStateVerifier implements SessionStateVerifier { + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + private final URI uri; + private final String token; + private final ObjectMapper mapper; + + public HttpSessionStateVerifier(String uri, String token, ObjectMapper mapper) { + this.uri = URI.create(uri); + this.token = token; + this.mapper = mapper; + } + + @Override public void verify(String jwtId, Long userId) { + try { + var request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(4)) + .header("Content-Type", "application/json").header("X-Internal-Service-Token", token) + .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes( + Map.of("jwtId", jwtId, "userId", userId)))).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + var body = mapper.readTree(response.body()); + if (response.statusCode() != 200 || body == null || !body.path("code").isTextual() || !"200".equals(body.path("code").asText()) + || !body.path("data").isBoolean() || !body.path("data").booleanValue()) { + throw new IllegalArgumentException("Authorization state is revoked or unavailable"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalArgumentException("Authorization state validation interrupted", failure); + } catch (Exception failure) { + throw new IllegalArgumentException("Authorization state is revoked or unavailable", failure); + } + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java index 394cecb5d..f82791313 100644 --- a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java @@ -12,15 +12,14 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio private final String keyPrefix; private final ObjectMapper objectMapper; - public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { - this(redisTemplate, keyPrefix, new ObjectMapper()); - } + private final SessionStateVerifier stateVerifier; public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, SessionStateVerifier stateVerifier) { this.redisTemplate = redisTemplate; this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); this.objectMapper = objectMapper; + this.stateVerifier = java.util.Objects.requireNonNull(stateVerifier); } @Override @@ -39,6 +38,7 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio if (userId == null || !userId.equals(session.userId())) { throw new IllegalArgumentException("Access token session identity mismatch"); } + stateVerifier.verify(jwtId, userId); return session; } catch (org.springframework.dao.DataAccessException exception) { throw new IllegalArgumentException("Token session store is unavailable", exception); diff --git a/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java b/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java new file mode 100644 index 000000000..b6f4f8823 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java @@ -0,0 +1,7 @@ +package com.baiyue.common.security; + +/** Verifies persisted authorization state, without positive caching. */ +@FunctionalInterface +public interface SessionStateVerifier { + void verify(String jwtId, Long userId); +} diff --git a/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java b/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java new file mode 100644 index 000000000..52bef14cb --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java @@ -0,0 +1,45 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class HttpSessionStateVerifierTest { + @Test void acceptsOnlyExplicitSuccessAndNeverForwardsBearer() throws Exception { + var body = new AtomicReference<>("{\"code\":\"200\",\"data\":true}"); + var auth = new AtomicReference(); + var internal = new AtomicReference(); + var input = new AtomicReference(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/validate", exchange -> { + auth.set(exchange.getRequestHeaders().getFirst("Authorization")); + internal.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")); + input.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + try { + var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + server.getAddress().getPort() + "/validate", "internal-only", new ObjectMapper()); + verifier.verify("session", 42L); + assertThat(auth.get()).isNull(); assertThat(internal.get()).isEqualTo("internal-only"); + assertThat(new ObjectMapper().readTree(input.get()).path("userId").longValue()).isEqualTo(42); + for (String rejected : new String[]{"{}", "{\"code\":200,\"data\":true}", "{\"code\":\"200\",\"data\":false}", + "{\"code\":\"A0403\",\"data\":true}", "{\"code\":\"200\",\"data\":\"true\"}", "not-json"}) { + body.set(rejected); + assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class); + } + } finally { server.stop(0); } + } + + @Test void unavailableAuthorityFailsClosed() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + int port = server.getAddress().getPort(); server.start(); server.stop(0); + var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + port + "/validate", "internal", new ObjectMapper()); + assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java index 307a44d56..8e94d2033 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -56,7 +56,7 @@ class JwtAuthenticationFilterTest { .thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}"); JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), - new RedisAccessTokenSessionValidator(redis, "baiyue:auth")); + new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), org.mockito.Mockito.mock(SessionStateVerifier.class))); MockHttpServletRequest request = new MockHttpServletRequest(); request.setRequestURI("/api/platform/v1/last-leg/orders"); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas"); diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java index 1f75a9232..618e0c2a1 100644 --- a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java @@ -24,7 +24,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}"); - assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))).doesNotThrowAnyException(); } @@ -35,7 +35,7 @@ class RedisAccessTokenSessionValidatorTest { when(redis.opsForValue()).thenReturn(values); when(values.get("baiyue:auth:access:jti-1")).thenReturn(null); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("revoked or expired"); @@ -49,7 +49,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenThrow(new RedisConnectionFailureException("offline")); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("unavailable"); @@ -63,7 +63,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}"); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("identity mismatch"); @@ -76,7 +76,7 @@ class RedisAccessTokenSessionValidatorTest { when(redis.opsForValue()).thenReturn(values); when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json"); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Invalid access token session"); diff --git a/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java index 4280bbe8e..e744ca648 100644 --- a/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java @@ -15,7 +15,7 @@ class UnifiedSessionModeTest { for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) { assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null, new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), - redis, new ObjectMapper(), "baiyue:auth", mode)) + redis, new ObjectMapper(), "baiyue:auth", mode, "http://auth:9100/internal/auth/accounts/sessions/validate", "test-token")) .isInstanceOf(IllegalStateException.class); } } diff --git a/common-web/pom.xml b/common-web/pom.xml index ee8c7876b..47be8e5ac 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -14,6 +14,8 @@ baiyue-common-web + com.baiyue.sharedcommon-event-contract${project.version} + com.h2databaseh2test com.baiyue.shared baiyue-common-core diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java index 25768312a..39749ef1b 100644 --- a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java @@ -3,6 +3,13 @@ package com.baiyue.common.web; import feign.Request; import feign.Capability; import feign.Retryer; +import feign.codec.Decoder; +import feign.codec.DecodeException; +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.ParameterizedType; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import java.util.concurrent.TimeUnit; @@ -22,6 +29,9 @@ public class EventDeliveryFeignConfiguration { } /** Applied at Feign build time after global/client properties, without changing other clients. */ public static final class DeliveryTransportCapability implements Capability { + private static final ObjectMapper RESPONSE_JSON = new ObjectMapper() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private final Request.Options options; public DeliveryTransportCapability(Request.Options options) { this.options = options; } @Override public Request.Options enrich(Request.Options ignored) { return options; } @@ -29,5 +39,26 @@ public class EventDeliveryFeignConfiguration { // The persisted Outbox owns the retry budget. return Retryer.NEVER_RETRY; } + @Override public Decoder enrich(Decoder decoder) { + return (response, type) -> { + if (type instanceof ParameterizedType parameterized && parameterized.getRawType() == ApiResponse.class) { + // Jackson normally coerces a numeric 200 into String. Inspect the wire value first. + if (response.body() == null) throw new DecodeException(response.status(), "Missing event response", response.request()); + byte[] bytes; + try (var stream = response.body().asInputStream()) { bytes = stream.readNBytes(65537); } + if (bytes.length > 65536) throw new DecodeException(response.status(), "Event response exceeds limit", response.request()); + try { + var root = RESPONSE_JSON.readTree(bytes); + if (root == null || !root.isObject() || !root.path("code").isTextual()) { + throw new IllegalArgumentException(); + } + } catch (Exception invalid) { + throw new DecodeException(response.status(), "Invalid event response contract", response.request()); + } + return decoder.decode(response.toBuilder().body(bytes).build(), type); + } + return decoder.decode(response, type); + }; + } } } diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java new file mode 100644 index 000000000..188b63a79 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java @@ -0,0 +1,78 @@ +package com.baiyue.common.web; + +import io.micrometer.core.instrument.Gauge; +import com.baiyue.common.event.EventDeliveryRoute; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.jdbc.core.JdbcTemplate; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Fixed labels only. Scheduled samplers query the local schema; scrape callbacks only read memory. */ +public final class EventDeliveryMetrics { + public enum Stream { + FIRSTLEG("firstleg", "fl_inbound_outbox", "publish_status", "PROCESSING", + List.of("FirstLegLoadingPlanCompleted", "FirstLegContainerShippingNodeRecorded")), + LASTLEG("lastleg", "ll_event_outbox", "status", "PROCESSING", + List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested")), + FEE("lastleg", "ll_event_outbox", "status", "PROCESSING", List.of("BusinessFeeSourceChanged")), + FINANCE("finance", "fin_wallet_event_outbox", "status", "SENDING", List.of("WalletCommandCompleted")); + public final String service, table, status, processing; + public final List types; + Stream(String service, String table, String status, String processing, List types) { + this.service = service; this.table = table; this.status = status; this.processing = processing; this.types = types; + } + public String type(String candidate) { return candidate != null && types.contains(candidate) ? candidate : "UNKNOWN"; } + public String target(String type) { + if (!types.contains(type)) return "UNRESOLVED"; // Audit only; never a transport fallback. + var route = EventDeliveryRoute.forEventType(type); + return route.serviceName() + route.path(); + } + } + private record Snapshot(double pending, double dead, double oldestSeconds, double sampledAt, double success) { } + private static final class Cell { volatile Snapshot value = new Snapshot(Double.NaN, Double.NaN, Double.NaN, 0, 0); } + private final JdbcTemplate jdbc; + private final MeterRegistry registry; + private final Map cells = new ConcurrentHashMap<>(); + public EventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { this.jdbc = jdbc; this.registry = registry; } + + public void sample(Stream stream, LocalDateTime now) { + for (String type : stream.types) { + Cell cell = cells.computeIfAbsent(stream.name() + ":" + type, ignored -> register(stream, type)); + try { + String filter = stream == Stream.FINANCE ? "" : " WHERE event_type=?"; + String sql = "SELECT SUM(CASE WHEN " + stream.status + " IN ('PENDING','" + stream.processing + + "') THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN " + stream.status + + "='DEAD' THEN 1 ELSE 0 END) AS dead_count, MIN(CASE WHEN " + stream.status + + " IN ('PENDING','" + stream.processing + "') THEN created_at END) AS oldest FROM " + stream.table + filter; + Map result = stream == Stream.FINANCE ? jdbc.queryForMap(sql) : jdbc.queryForMap(sql, type); + LocalDateTime oldest = databaseTime(result.get("oldest")); + cell.value = new Snapshot(count(result.get("pending_count")), count(result.get("dead_count")), + oldest == null ? 0 : Math.max(0, Duration.between(oldest, now).toMillis() / 1000.0), + System.currentTimeMillis() / 1000.0, 1); + } catch (RuntimeException failure) { + Snapshot old = cell.value; + cell.value = new Snapshot(old.pending, old.dead, old.oldestSeconds, old.sampledAt, 0); + } + } + } + private Cell register(Stream stream, String type) { + Cell cell = new Cell(); + Gauge.builder("baiyue.event.delivery.pending", cell, c -> c.value.pending).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.dead", cell, c -> c.value.dead).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.oldest.pending.seconds", cell, c -> c.value.oldestSeconds).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.sample.timestamp", cell, c -> c.value.sampledAt).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.sample.success", cell, c -> c.value.success).tags("service", stream.service, "event_type", type).register(registry); + return cell; + } + private static LocalDateTime databaseTime(Object value) { + if (value == null) return null; + if (value instanceof LocalDateTime local) return local; + if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); + throw new IllegalArgumentException("Unsupported Outbox timestamp type"); + } + private static double count(Object value) { return value == null ? 0 : ((Number) value).doubleValue(); } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java new file mode 100644 index 000000000..dc184aef3 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java @@ -0,0 +1,29 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.common.event.EventIdentifiers; +import feign.FeignException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +/** Diagnostic identity and finite audit error codes; never serialize exception messages or payloads. */ +public final class EventDeliveryObservation { + private EventDeliveryObservation() { } + public static String eventId(ObjectMapper mapper, String json) { + try { + String id = mapper.readTree(json).path("eventId").asText(""); + return EventIdentifiers.isSupported(id) ? id : null; + } catch (Exception invalid) { return null; } // Invalid payloads are still audited by Outbox entry ID. + } + public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) { + if (cause instanceof FeignException http && http.status() >= 100 && http.status() <= 599) { + return "HTTP_" + http.status(); + } + } + if (!requestStarted) return "INVALID_EVENT"; + return permanent ? "PERMANENT_FAILURE" : "DELIVERY_FAILURE"; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java new file mode 100644 index 000000000..efa4c05f8 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +@AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}) +@ConditionalOnBean(EventDeliveryProducer.class) +public class EventDeliveryObservationAutoConfiguration { + @Bean + EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { + return new EventDeliveryJournal(jdbc, manager); + } + @Bean + EventDeliveryMetrics eventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { + return new EventDeliveryMetrics(jdbc, registry); + } +} diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 3fb8cf813..90fd2005c 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -3,3 +3,4 @@ com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration com.baiyue.common.web.RestClientTracePropagationAutoConfiguration com.baiyue.common.web.BaiyueSchedulingAutoConfiguration +com.baiyue.common.web.EventDeliveryObservationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java index dd94c9ff5..962c11d4b 100644 --- a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java @@ -11,6 +11,31 @@ import static org.assertj.core.api.Assertions.*; class EventDeliveryFeignConfigurationTest { interface Endpoint { @RequestLine("POST /events") String send(); } + interface TypedEndpoint { @RequestLine("POST /events") com.baiyue.common.core.api.ApiResponse> send(); } + + @Test void wireCodeMustBeAStringAndAmbiguousJsonCannotBeAcknowledged() { + for (String body : List.of("{\"code\":200}", "{\"code\":true}", "{\"code\":\"200\",\"code\":\"500\"}", "{\"code\":\"200\"} {}", "{}")) { + var decoded = new AtomicInteger(); + TypedEndpoint endpoint = Feign.builder() + .decoder((response, type) -> { decoded.incrementAndGet(); return com.baiyue.common.core.api.ApiResponse.success(Map.of()); }) + .addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build()) + .target(TypedEndpoint.class, "http://event-consumer.invalid"); + assertThatThrownBy(endpoint::send).isInstanceOf(feign.codec.DecodeException.class); + assertThat(decoded.get()).isZero(); + } + } + + @Test void validStringCodePreservesBodyForTheConfiguredDecoder() { + String body = "{\"code\":\"200\",\"data\":{\"outcome\":\"APPLIED\"}}"; + TypedEndpoint endpoint = Feign.builder().decoder((response, type) -> { + assertThat(new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8)).isEqualTo(body); + return com.baiyue.common.core.api.ApiResponse.success(Map.of("outcome", "APPLIED")); + }).addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build()) + .target(TypedEndpoint.class, "http://event-consumer.invalid"); + assertThat(endpoint.send().data()).containsEntry("outcome", "APPLIED"); + } @Test void builtClientOverridesLegacyTimeoutAndRedirectSettings() { var capability = new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60); diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java new file mode 100644 index 000000000..6a9e9da25 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java @@ -0,0 +1,71 @@ +package com.baiyue.common.web; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.time.LocalDateTime; +import java.sql.Timestamp; +import java.util.Map; +import java.util.List; +import static org.mockito.ArgumentMatchers.*; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventDeliveryMetricsTest { + @Test void samplesBoundedTypesAndPreservesPriorValuesWhenDatabaseFailsWithoutQueryingDuringScrapes() { + var jdbc = spy(new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""))); + jdbc.execute("CREATE TABLE ll_event_outbox(event_type VARCHAR(200),status VARCHAR(32),created_at TIMESTAMP)"); + var now = LocalDateTime.now(); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PENDING',?)", now.minusSeconds(60)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PROCESSING',?)", now.minusSeconds(30)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','DEAD',?)", now.minusSeconds(120)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('user-controlled-unknown-type','PENDING',?)", now.minusYears(1)); + var registry = new SimpleMeterRegistry(); + var metrics = new EventDeliveryMetrics(jdbc, registry); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2); + assertThat(registry.get("baiyue.event.delivery.dead").gauge().value()).isEqualTo(1); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isBetween(59.99, 60.01); + double sampledAt = registry.get("baiyue.event.delivery.sample.timestamp").gauge().value(); + jdbc.execute("DROP TABLE ll_event_outbox"); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now.plusMinutes(1)); + clearInvocations(jdbc); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2); + assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isEqualTo(sampledAt); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + registry.getMeters().forEach(meter -> { + assertThat(meter.getId().getTag("event_type")).isEqualTo("BusinessFeeSourceChanged"); + meter.measure().forEach(measurement -> measurement.getValue()); + }); + verifyNoInteractions(jdbc); + } + @Test void supportsBothMysqlLocalDateTimeAndJdbcTimestampAndRejectsUnknownTypes() { + var jdbc = mock(JdbcTemplate.class); + var now = LocalDateTime.of(2026, 9, 29, 12, 0); + var registry = new SimpleMeterRegistry(); + var metrics = new EventDeliveryMetrics(jdbc, registry); + for (Object oldest : List.of(now.minusSeconds(45), Timestamp.valueOf(now.minusSeconds(45)))) { + Map row = Map.of("pending_count", 1L, "dead_count", 0L, "oldest", oldest); + when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged"))).thenReturn(row); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isEqualTo(1); + } + when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged"))) + .thenReturn(Map.of("pending_count", 0L, "dead_count", 0L, "oldest", "not a JDBC time")); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(1); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + } + @Test void initialFailureIsUnknownRatherThanFalseZero() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID(), "sa", "")); + var registry = new SimpleMeterRegistry(); + new EventDeliveryMetrics(jdbc, registry).sample(EventDeliveryMetrics.Stream.FINANCE, LocalDateTime.now()); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isNaN(); + assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isZero(); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java new file mode 100644 index 000000000..19a1a9674 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java @@ -0,0 +1,53 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryObservationAutoConfigurationTest { + @Test void configuresMandatoryAuditAndMetricsForServiceLocalJdbc() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:auto", "sa", "")); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> jdbc) + .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> { + assertThat(context).hasSingleBean(EventDeliveryJournal.class); + assertThat(context).hasSingleBean(EventDeliveryMetrics.class); + }); + } + @Test void otherJdbcServicesDoNotAcquireTransactionOrMetricsRequirements() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:other", "sa", ""))) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(EventDeliveryJournal.class); + assertThat(context).doesNotHaveBean(EventDeliveryMetrics.class); + }); + } + @Test void producerWithoutMeterRegistryFailsInsteadOfSilentlyDisablingObservation() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noRegistry", "sa", "")); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> jdbc) + .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) + .run(context -> assertThat(context).hasFailed()); + } + @Test void missingTransactionManagerFailsRatherThanInstallingNonTransactionalFallback() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:missing", "sa", ""))) + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> assertThat(context).hasFailed()); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java new file mode 100644 index 000000000..83f6a9ef8 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java @@ -0,0 +1,22 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryObservationTest { + @Test void auditIdentitySupportsTheFormalUuidAndUlidContractWithoutSavingMalformedPayload() { + var mapper = new ObjectMapper(); + for (String id : new String[]{"00000000-0000-4000-8000-000000000019", "01ARZ3NDEKTSV4RRFFQ69G5FAV"}) { + assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"" + id + "\",\"payload\":\"secret\"}")).isEqualTo(id); + } + assertThat(EventDeliveryObservation.eventId(mapper, "bad secret JSON")).isNull(); + assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"Bearer secret\"}")).isNull(); + } + @Test void arbitraryExceptionsAndRemoteMessagesCannotLeakIntoAttemptAudit() { + var failure = new IllegalStateException("payload={secret} Authorization=secret"); + assertThat(EventDeliveryObservation.errorCode(failure, false, true)).isEqualTo("DELIVERY_FAILURE"); + assertThat(EventDeliveryObservation.errorCode(failure, true, true)).isEqualTo("PERMANENT_FAILURE"); + assertThat(EventDeliveryObservation.errorCode(failure, true, false)).isEqualTo("INVALID_EVENT"); + } +} diff --git a/config/nacos/cn/README.md b/config/nacos/cn/README.md deleted file mode 100644 index 704d7ffbf..000000000 --- a/config/nacos/cn/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# 国内 Nacos 配置 - -将本目录文件按原文件名作为 Data ID 发布到 `prod-cn` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `cn` Profile。 - -尾程配置不进入本仓库;头程和尾程只通过跨区域事件桥协作。 diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml deleted file mode 100644 index 891768268..000000000 --- a/config/nacos/cn/auth-service.yml +++ /dev/null @@ -1,17 +0,0 @@ -spring: - datasource: - url: ${AUTH_DB_URL} - username: ${AUTH_DB_USERNAME} - password: ${AUTH_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - security: - access-token-ttl: ${ACCESS_TOKEN_TTL:24m} - refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:true} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml deleted file mode 100644 index 27a06967c..000000000 --- a/config/nacos/cn/event-bridge-service.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - datasource: - url: ${EVENT_BRIDGE_DB_URL} - username: ${EVENT_BRIDGE_DB_USERNAME} - password: ${EVENT_BRIDGE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - region: ${REGION_CODE:CN} - deployment-cell: ${DEPLOYMENT_CELL:prod-cn} - data-residency: ${DATA_RESIDENCY:CN} - event-dispatcher: - consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} - firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} - finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events} - finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events} diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml deleted file mode 100644 index aa39d45b0..000000000 --- a/config/nacos/cn/finance-service.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - datasource: - url: ${FINANCE_DB_URL} - username: ${FINANCE_DB_USERNAME} - password: ${FINANCE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} -baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/README.md b/config/nacos/local/README.md deleted file mode 100644 index ce6d3e445..000000000 --- a/config/nacos/local/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# 头程本地 Nacos 配置 - -本目录配置用于本机开发环境,发布目标为 `public` 命名空间和 `BAIYUE` 分组。 - -配置通过环境变量保留覆盖能力,并提供当前本机可直接启动的默认地址。生产环境必须使用 `config/nacos/cn`,不得使用本目录中的本地密码和本地密钥。 - -发布命令: - -```powershell -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1 -``` - -也可以使用统一入口;`local` 会被强制限制在 `public` namespace: - -```powershell -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local -``` - -配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。 - -本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括 -`gateway-overseas.yml`、`lastleg-service.yml` 和 -`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机 -Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。 - -## MyBatis SQL 日志 - -本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。 - -可通过环境变量统一调整: - -```powershell -$env:MYBATIS_SQL_LOG_LEVEL = "INFO" # 关闭 SQL 明细 -$env:MYBATIS_SQL_LOG_LEVEL = "DEBUG" # 打开 SQL 明细 -``` - -修改后重新发布本地 Nacos 配置并重启相关服务。SQL 参数可能包含业务数据,该设置只用于本地开发,不得复制到测试或生产环境。 diff --git a/config/nacos/local/audit-service.yml b/config/nacos/local/audit-service.yml deleted file mode 100644 index f1d414ec6..000000000 --- a/config/nacos/local/audit-service.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUDIT_DB_USERNAME:root} - password: ${AUDIT_DB_PASSWORD:123456} diff --git a/config/nacos/local/auth-service.yml b/config/nacos/local/auth-service.yml deleted file mode 100644 index 64d7b2be0..000000000 --- a/config/nacos/local/auth-service.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUTH_DB_USERNAME:root} -password: ${AUTH_DB_PASSWORD:123456} - -baiyue: - security: - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:true} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml deleted file mode 100644 index 5acc5cbe2..000000000 --- a/config/nacos/local/baiyue-common.yml +++ /dev/null @@ -1,73 +0,0 @@ -baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:local-cn} - data-residency: ${DATA_RESIDENCY:CN} - security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - issuer: ${JWT_ISSUER:auth-service} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} - warehouse: - public-tenant-id: 0 - openapi.enabled: ${SWAGGER_ENABLED:true} - audit: - enabled: true - endpoint: ${AUDIT_SERVICE_URL:http://127.0.0.1:9700}/internal/audit/events - outbox: - enabled: ${AUDIT_OUTBOX_ENABLED:false} - publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false} - batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50} - lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60} - max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5} - max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2} - compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} - retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} - rocketmq: - topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 - send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} -spring: - jackson: - default-property-inclusion: non_null - deserialization.fail-on-unknown-properties: false - serialization.write-dates-as-timestamps: false - data: - redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - timeout: ${REDIS_TIMEOUT:3s} - cloud: - nacos.discovery: - # Local services must stay reachable through the gateway when VPN adapters change priority. - ip: ${NACOS_DISCOVERY_IP:127.0.0.1} - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - openfeign: - circuitbreaker.enabled: true - client.config.default: - connect-timeout: 3000 - read-timeout: 5000 -server: - shutdown: graceful - max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} -management: - endpoints.web.exposure.include: health,info,prometheus - endpoint.health: - probes.enabled: true - show-details: always -mybatis-plus: - configuration: - map-underscore-to-camel-case: true - call-setters-on-nulls: true - global-config: - banner: false - db-config: - id-type: assign_id - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} diff --git a/config/nacos/local/event-bridge-service.yml b/config/nacos/local/event-bridge-service.yml deleted file mode 100644 index e70f98f7f..000000000 --- a/config/nacos/local/event-bridge-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} - username: ${EVENT_BRIDGE_DB_USERNAME:root} - password: ${EVENT_BRIDGE_DB_PASSWORD:123456} -baiyue: - region: ${REGION_CODE:CN} - event-bridge: - accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true} - deployment-cell: ${DEPLOYMENT_CELL:local-cn} - data-residency: ${DATA_RESIDENCY:CN} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml deleted file mode 100644 index 31fcdb590..000000000 --- a/config/nacos/local/file-service.yml +++ /dev/null @@ -1,123 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FILE_DB_USERNAME:root} - password: ${FILE_DB_PASSWORD:123456} -baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} -baiyue: - file: - export: - definitions: - CUSTOMER_LIST: - provider-service: iam-service - provider-path: /internal/exports/v1/CUSTOMER_LIST/batches - required-permission: platform:customer:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - - DOMESTIC_WAREHOUSE_ORDER_LIST: - provider-service: order-service - provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches - required-permission: platform:order:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 200 - - LOADING_LIST_BUY_DECLARATION_ITEMS: - provider-service: firstleg-service - provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches - required-permission: platform:firstleg:customs:manage - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - - FINANCE_CUSTOMER_ACCOUNT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches - required-permission: platform:finance:customer-account:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_CUSTOMER_ACCOUNT_LEDGER: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches - required-permission: platform:finance:customer-account:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_FEE_SOURCE_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches - required-permission: platform:finance:customer-arap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECONCILIATION_STATEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches - required-permission: platform:finance:customer-statement:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_SETTLEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches - required-permission: platform:finance:settlement-ar:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches - required-permission: platform:finance:settlement-ar:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_PAYABLE_SETTLEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches - required-permission: platform:finance:settlement-ap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_PAYABLE_SETTLEMENT_DETAIL: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches - required-permission: platform:finance:settlement-ap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_STATISTICS: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches - required-permission: platform:finance:receivable-statistics:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 -baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} -baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml deleted file mode 100644 index 6269c2f7a..000000000 --- a/config/nacos/local/finance-service.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FINANCE_DB_USERNAME:root} - password: ${FINANCE_DB_PASSWORD:123456} -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg} -baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml deleted file mode 100644 index de2ce6708..000000000 --- a/config/nacos/local/firstleg-service.yml +++ /dev/null @@ -1,69 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FIRSTLEG_DB_USERNAME:root} - password: ${FIRSTLEG_DB_PASSWORD:123456} -spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB} -spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB} -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.clients.order.url: ${ORDER_SERVICE_URL:} -baiyue.clients.file.url: ${FILE_SERVICE_URL:} -baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} -baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} -baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} -baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} -baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} -baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} -baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} -baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} -baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} -firstleg: - lingxing: - auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} - base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} - login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com} - provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01} - # Firstleg and the isolated Lingxing browser run on the same Docker network. - # Keep the environment override for deployments that use another endpoint. - browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222} - username: ${LINGXING_USERNAME:} - password: ${LINGXING_PASSWORD:} - browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true} - token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} - token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} - track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:} - request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s} - warehouses: [US01, US02] - inbound: - allow-rejected-measurement: false - default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} - reservation-ttl: ${PALLET_RESERVATION_TTL:30m} - warehouse-resource-tenant-id: 0 - measurement-openapi: - enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} - # 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。 - signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false} - max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} - nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} - apps: - # 墨家使用平台级 AppKey;tenant-id 固定为 0,按全局箱码解析真实客户。 - # warehouse-bindings 的键必须与设备上传的 machineNo 完全一致。 - mojia: - enabled: ${MOJIA_MEASUREMENT_ENABLED:false} - secret: ${MOJIA_MEASUREMENT_SECRET:} - tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0} - subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0} - warehouse-bindings: - mojia-yiwu-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0} - # 历史通用应用配置,保留兼容;新接入请使用 mojia。 - device-provider: - enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false} - secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:} - tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0} - subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} - warehouse-bindings: - CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} diff --git a/config/nacos/local/gateway-overseas.yml b/config/nacos/local/gateway-overseas.yml deleted file mode 100644 index ea2ef8cb5..000000000 --- a/config/nacos/local/gateway-overseas.yml +++ /dev/null @@ -1,16 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} -baiyue: - security: - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} - pda-session-validation: - auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} - overseas-projection: - service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/local/gateway.yml b/config/nacos/local/gateway.yml deleted file mode 100644 index c31d6422d..000000000 --- a/config/nacos/local/gateway.yml +++ /dev/null @@ -1,11 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} -baiyue: - cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174} - allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,PATCH,DELETE,OPTIONS} - allowed-headers: ${CORS_ALLOWED_HEADERS:*} - exposed-headers: ${CORS_EXPOSED_HEADERS:X-Request-Id,X-Trace-Id} - allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} - max-age: ${CORS_MAX_AGE:3600} diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml deleted file mode 100644 index a1a4c5f90..000000000 --- a/config/nacos/local/iam-service.yml +++ /dev/null @@ -1,16 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${IAM_DB_USERNAME:root} - password: ${IAM_DB_PASSWORD:123456} -baiyue: - security-event: - # 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。 - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312} - customer-dimension-initialization: - fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} - max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} - notification: - scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} - dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} - mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml deleted file mode 100644 index b02d9d323..000000000 --- a/config/nacos/local/integration-service.yml +++ /dev/null @@ -1,53 +0,0 @@ -spring: - servlet: - multipart: - max-file-size: 5MB - max-request-size: 6MB - -baiyue: - integration: - baidu-ocr: - enabled: ${BAIDU_OCR_ENABLED:false} - input-root: ${WEIGHT_OCR_INPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/input} - output-root: ${WEIGHT_OCR_OUTPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/output} - max-image-size: ${WEIGHT_OCR_MAX_IMAGE_SIZE:5MB} - max-image-pixels: ${WEIGHT_OCR_MAX_IMAGE_PIXELS:40000000} - max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} - amazon-sp-api: - enabled: ${AMAZON_SP_API_ENABLED:false} - carrier-schedule: - matson: - enabled: ${MATSON_TRACKING_ENABLED:false} - page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} - query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} - connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} - hede: - enabled: ${HEDE_TRACKING_ENABLED:false} - query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} - connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} - sml: - enabled: ${SML_TRACKING_ENABLED:false} - query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} - connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} - zim: - enabled: ${ZIM_TRACKING_ENABLED:false} - authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} - tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} - client-id: ${ZIM_CLIENT_ID:} - client-secret: ${ZIM_CLIENT_SECRET:} - subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} - connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} - max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} - evergreen: - enabled: ${EMC_TRACKING_ENABLED:false} - query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} - connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml deleted file mode 100644 index 7b8f3bd01..000000000 --- a/config/nacos/local/lastleg-service.yml +++ /dev/null @@ -1,29 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${LASTLEG_DB_USERNAME:root} - password: ${LASTLEG_DB_PASSWORD:123456} -lastleg: - dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} - smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} -baiyue: - clients: - domestic-warehouse: - url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} - cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} - file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} - warehouse: - base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305} - firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - jwt-mode: jwks - session-mode: redis - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml deleted file mode 100644 index 16d034278..000000000 --- a/config/nacos/local/order-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${ORDER_DB_USERNAME:root} - password: ${ORDER_DB_PASSWORD:123456} -baiyue.clients: - warehouse.url: ${WAREHOUSE_SERVICE_URL:} - -baiyue.order: - # 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。 - inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO} diff --git a/config/nacos/local/print-service.yml b/config/nacos/local/print-service.yml deleted file mode 100644 index c3fc80ed8..000000000 --- a/config/nacos/local/print-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -print: - pdf: - max-pages: ${PRINT_PDF_MAX_PAGES:2000} - max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152} - max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432} - max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4} - acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s} - template-code: INBOUND_MARK_V1 - supported-width-mm: 100 - supported-height-mm: 100 - font-resource: fonts/NotoSansSC-Bold.ttf diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml deleted file mode 100644 index a3b8cf9ee..000000000 --- a/config/nacos/local/warehouse-overseas-service.yml +++ /dev/null @@ -1,19 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} -baiyue: - clients: - domestic-warehouse: - url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} - cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} - internal-service: - token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - security: - jwt-mode: jwks - session-mode: redis - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/warehouse-service.yml b/config/nacos/local/warehouse-service.yml deleted file mode 100644 index b0e8a969c..000000000 --- a/config/nacos/local/warehouse-service.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} diff --git a/config/nacos/overseas/README.md b/config/nacos/overseas/README.md deleted file mode 100644 index 71866504e..000000000 --- a/config/nacos/overseas/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# 海外 Nacos 配置 - -本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。 - -海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 - -发布示例: - -```powershell -# 开发环境 -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` - -Environment development -Region overseas -ServerAddr -Namespace - -# 测试环境 -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` - -Environment test -Region overseas -ServerAddr -Namespace -``` - -远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。 diff --git a/config/nacos/overseas/audit-service.yml b/config/nacos/overseas/audit-service.yml deleted file mode 100644 index 1ac49f251..000000000 --- a/config/nacos/overseas/audit-service.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - url: ${AUDIT_DB_URL} - username: ${AUDIT_DB_USERNAME} - password: ${AUDIT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml deleted file mode 100644 index a5e5cb364..000000000 --- a/config/nacos/overseas/baiyue-common.yml +++ /dev/null @@ -1,67 +0,0 @@ -baiyue: - deployment: - region-code: ${REGION_CODE:OVERSEAS} - cell: ${DEPLOYMENT_CELL:prod-overseas} - data-residency: ${DATA_RESIDENCY:OVERSEAS} - security: - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - pda-session-validation: - auth-url: ${AUTH_SESSION_VALIDATION_URL} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} - openapi.enabled: ${SWAGGER_ENABLED:false} - audit.enabled: true -spring: - jackson: - default-property-inclusion: non_null - deserialization: - fail-on-unknown-properties: false - serialization: - write-dates-as-timestamps: false - data: - redis: - host: ${REDIS_HOST:redis-overseas.internal} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - timeout: ${REDIS_TIMEOUT:3s} - cloud: - nacos: - discovery: - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-overseas} - openfeign: - circuitbreaker.enabled: true - client.config.default: - connect-timeout: 3000 - read-timeout: 5000 -server: - max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} - shutdown: graceful -management: - endpoints: - web: - exposure: - include: health,info,prometheus - endpoint: - health: - probes.enabled: true - show-details: never -mybatis-plus: - configuration: - map-underscore-to-camel-case: true - call-setters-on-nulls: true - global-config: - banner: false - db-config: - id-type: assign_id - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml deleted file mode 100644 index 00a8ac755..000000000 --- a/config/nacos/overseas/event-bridge-service.yml +++ /dev/null @@ -1,21 +0,0 @@ -spring: - datasource: - url: ${EVENT_BRIDGE_DB_URL} - username: ${EVENT_BRIDGE_DB_USERNAME} - password: ${EVENT_BRIDGE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - region: ${REGION_CODE:OVERSEAS} - deployment-cell: ${DEPLOYMENT_CELL:prod-overseas} - data-residency: ${DATA_RESIDENCY:OVERSEAS} - event-dispatcher: - consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/security/v1/events} - fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} - batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} - max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} - lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} - initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} - max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} - connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} - request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} diff --git a/config/nacos/overseas/file-service.yml b/config/nacos/overseas/file-service.yml deleted file mode 100644 index 971474618..000000000 --- a/config/nacos/overseas/file-service.yml +++ /dev/null @@ -1,13 +0,0 @@ -spring: - datasource: - url: ${FILE_DB_URL} - username: ${FILE_DB_USERNAME} - password: ${FILE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -file: - storage: - provider: ${FILE_STORAGE_PROVIDER:s3} - endpoint: ${FILE_STORAGE_ENDPOINT:} - bucket: ${FILE_STORAGE_BUCKET:} - access-key: ${FILE_STORAGE_ACCESS_KEY:} - secret-key: ${FILE_STORAGE_SECRET_KEY:} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml deleted file mode 100644 index a9c1eadf2..000000000 --- a/config/nacos/overseas/gateway-overseas.yml +++ /dev/null @@ -1,9 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} -baiyue: - security: - overseas-projection: - service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/integration-service.yml b/config/nacos/overseas/integration-service.yml deleted file mode 100644 index 25617fae1..000000000 --- a/config/nacos/overseas/integration-service.yml +++ /dev/null @@ -1,12 +0,0 @@ -baiyue: - integration: - baidu-ocr: - enabled: ${BAIDU_OCR_ENABLED:false} - amazon-sp-api: - enabled: ${AMAZON_SP_API_ENABLED:true} - client-id: ${AMAZON_SP_API_CLIENT_ID:} - client-secret: ${AMAZON_SP_API_CLIENT_SECRET:} - refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:} - aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} - aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} - region: ${AMAZON_SP_API_REGION:} diff --git a/config/nacos/overseas/print-service.yml b/config/nacos/overseas/print-service.yml deleted file mode 100644 index 613433e3f..000000000 --- a/config/nacos/overseas/print-service.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - url: ${PRINT_DB_URL} - username: ${PRINT_DB_USERNAME} - password: ${PRINT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/test/README.md b/config/nacos/test/README.md new file mode 100644 index 000000000..5680b52c3 --- /dev/null +++ b/config/nacos/test/README.md @@ -0,0 +1,9 @@ +# TEST 单机配置 + +唯一导入目录为本目录的 14 个 YAML:`baiyue-common.yml` 加 13 个服务 Data ID。Group 默认 `BAIYUE`,所有配置和发现共用实际 `NACOS_NAMESPACE` ID;不能填写命名空间显示名。先导入公共配置再导入服务配置。应用不再使用 optional import,缺失配置应阻断启动。 + +变量在服务进程内解析,导入文件不得替换成真实密钥。无密钥模板见 `../../../deploy/env/.env.test.example`。私钥仅传入 Auth;运维令牌仅传入 Firstleg、Finance、Lastleg。各业务数据库 Schema 独立。 + +离线预检:`./scripts/publish-nacos-config.ps1 -ServerAddr '' -Namespace '' -DryRun`(从后端根运行,不联网、不登录)。正式发布去掉 DryRun,会修改指定 Nacos,需授权;认证从进程环境 NACOS_USERNAME/NACOS_PASSWORD 取得。失败立即停止,已发布项不自动撤销,保存本轮配置版本并在停写窗口重新发布完整匹配集合。 + +切换顺序、发布与恢复约束见 `../../../deploy/TEST单机部署执行清单.md`;文件存在不代表环境已发布。 diff --git a/config/nacos/cn/audit-service.yml b/config/nacos/test/audit-service.yml similarity index 100% rename from config/nacos/cn/audit-service.yml rename to config/nacos/test/audit-service.yml diff --git a/config/nacos/test/auth-service.yml b/config/nacos/test/auth-service.yml new file mode 100644 index 000000000..10ae3fae1 --- /dev/null +++ b/config/nacos/test/auth-service.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: ${AUTH_DB_URL} + username: ${AUTH_DB_USERNAME} + password: ${AUTH_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + security: + access-token-ttl: ${ACCESS_TOKEN_TTL:24m} + refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} +baiyue.security.jwt.rsa-jwk-set: ${JWT_RSA_JWK_SET} +baiyue.security.jwt.current-kid: ${JWT_CURRENT_KID} diff --git a/config/nacos/cn/baiyue-common.yml b/config/nacos/test/baiyue-common.yml similarity index 81% rename from config/nacos/cn/baiyue-common.yml rename to config/nacos/test/baiyue-common.yml index f7318ae43..db9463ead 100644 --- a/config/nacos/cn/baiyue-common.yml +++ b/config/nacos/test/baiyue-common.yml @@ -1,10 +1,9 @@ baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:prod-cn} - data-residency: ${DATA_RESIDENCY:CN} security: - jwt-secret: ${JWT_SECRET} + jwt-mode: jwks + session-mode: redis + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} issuer: ${JWT_ISSUER:auth-service} internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} @@ -24,7 +23,7 @@ baiyue: compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} rocketmq: - topic: ${MQ_TOPIC_PREFIX:}baiyue-audit-event-v1 + topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} spring: jackson: @@ -35,7 +34,8 @@ spring: write-dates-as-timestamps: false data: redis: - host: ${REDIS_HOST:redis-cn.internal} + username: ${REDIS_USERNAME:default} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} @@ -44,7 +44,7 @@ spring: nacos: discovery: group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-cn} + namespace: ${NACOS_NAMESPACE} openfeign: circuitbreaker.enabled: true client.config.default: @@ -76,3 +76,5 @@ mybatis-plus: springdoc: api-docs.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false} + +baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate} diff --git a/config/nacos/cn/file-service.yml b/config/nacos/test/file-service.yml similarity index 100% rename from config/nacos/cn/file-service.yml rename to config/nacos/test/file-service.yml diff --git a/config/nacos/test/finance-service.yml b/config/nacos/test/finance-service.yml new file mode 100644 index 000000000..bbc4da782 --- /dev/null +++ b/config/nacos/test/finance-service.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: ${FINANCE_DB_URL} + username: ${FINANCE_DB_USERNAME} + password: ${FINANCE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} +baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} +baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} +baiyue.event-delivery.observation-enabled: true +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/test/firstleg-service.yml similarity index 87% rename from config/nacos/cn/firstleg-service.yml rename to config/nacos/test/firstleg-service.yml index 42b868723..ca18c236b 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/test/firstleg-service.yml @@ -29,11 +29,8 @@ baiyue: max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} outbox-relay: fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} firstleg: lingxing: # 安全默认关闭;开启后自动任务才允许调用领星新品维护接口。 @@ -81,3 +78,12 @@ firstleg: subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} warehouse-bindings: CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} +baiyue.event-delivery.observation-enabled: true +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/gateway.yml b/config/nacos/test/gateway.yml similarity index 100% rename from config/nacos/cn/gateway.yml rename to config/nacos/test/gateway.yml diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/test/iam-service.yml similarity index 84% rename from config/nacos/cn/iam-service.yml rename to config/nacos/test/iam-service.yml index f735164e7..659d5044e 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/test/iam-service.yml @@ -5,8 +5,6 @@ spring: password: ${IAM_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue: - security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} customer-dimension-initialization: fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/test/integration-service.yml similarity index 100% rename from config/nacos/cn/integration-service.yml rename to config/nacos/test/integration-service.yml diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/test/lastleg-service.yml similarity index 77% rename from config/nacos/overseas/lastleg-service.yml rename to config/nacos/test/lastleg-service.yml index c468507d4..b555e61c4 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/test/lastleg-service.yml @@ -45,13 +45,17 @@ baiyue: url: ${FILE_SERVICE_URL} domestic-file: url: ${FILE_SERVICE_URL} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} warehouse: base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} +baiyue.event-delivery.observation-enabled: true +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/order-service.yml b/config/nacos/test/order-service.yml similarity index 100% rename from config/nacos/cn/order-service.yml rename to config/nacos/test/order-service.yml diff --git a/config/nacos/cn/print-service.yml b/config/nacos/test/print-service.yml similarity index 100% rename from config/nacos/cn/print-service.yml rename to config/nacos/test/print-service.yml diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/test/warehouse-overseas-service.yml similarity index 100% rename from config/nacos/overseas/warehouse-overseas-service.yml rename to config/nacos/test/warehouse-overseas-service.yml diff --git a/config/nacos/cn/warehouse-service.yml b/config/nacos/test/warehouse-service.yml similarity index 100% rename from config/nacos/cn/warehouse-service.yml rename to config/nacos/test/warehouse-service.yml diff --git a/deploy/.env.overseas-auth.example b/deploy/.env.overseas-auth.example deleted file mode 100644 index 325a37c3d..000000000 --- a/deploy/.env.overseas-auth.example +++ /dev/null @@ -1,12 +0,0 @@ -# Auth signs short-lived overseas RS256 tokens with a private JWK Set. -OVERSEAS_JWT_ENABLED=true -OVERSEAS_JWT_ISSUER=auth-service -OVERSEAS_JWT_AUDIENCE=lastleg-api -OVERSEAS_JWT_CURRENT_KID=replace-with-current-signing-kid -OVERSEAS_ACCESS_TOKEN_TTL=15m -OVERSEAS_JWT_RSA_JWK_SET={"keys":[{"kty":"RSA","kid":"replace-with-current-signing-kid","use":"sig","alg":"RS256","n":"replace-with-modulus","e":"AQAB","d":"replace-with-private-exponent","p":"replace","q":"replace","dp":"replace","dq":"replace","qi":"replace"}]} - -# Overseas services validate tokens against Auth's public JWKS endpoint. -JWT_JWK_SET_URI=https://replace-with-auth-host/api/auth/.well-known/jwks.json -JWT_ISSUER=auth-service -JWT_AUDIENCE=lastleg-api diff --git a/deploy/TEST单机部署执行清单.md b/deploy/TEST单机部署执行清单.md new file mode 100644 index 000000000..085b9618f --- /dev/null +++ b/deploy/TEST单机部署执行清单.md @@ -0,0 +1,42 @@ +# TEST 单机部署执行清单 + +本清单是本地可审核目标,尚未部署或完成真实环境验证。唯一入口:`docker-compose.yml`、`service-map.yml`、`env/.env.test.example` 与 `../config/nacos/test/`。正式规则见外层 `docs/测试环境单机部署与配置契约.md`、`docs/统一认证与服务通信契约.md`。 + +## 发布前清单 + +1. 记录13个服务镜像 tag/digest、后端和四端版本、SQL基线及14个Nacos配置版本。统一服务集合:Gateway/Auth/IAM/Order/Firstleg/Finance/Warehouse/Warehouse Overseas/Lastleg/File/Print/Audit/Integration。不存在单独海外Gateway或事件桥发布项。 +2. 人工核对实际Nacos Namespace ID、Group、主机容量、文件卷UID 10001读写权限、MySQL各Schema、Redis DB/会话前缀、现有MQ topic/group。环境模板的Schema名只是待核基线,不能据此重命名运行库。 +3. 所有容器共用 firstleg-test-network,并接外部 INFRA_NETWORK(默认 firstleg-test-infra-network)。中间件运行在宿主机时使用 host.docker.internal;运行在容器时用同网络服务名。禁止跨容器 localhost。Nacos应用名不等于Docker服务名,Auth HTTP地址使用 auth:9100。 +4. Gateway仅向宿主机127.0.0.1发布9000(可配置GATEWAY_PUBLISHED_PORT)。同网络Nginx转发 gateway:9000;其余业务仅内部expose。不可把内部API端口直接发布给用户。 +5. Auth私钥JWT_RSA_JWK_SET与JWT_CURRENT_KID由密钥凭据提供;所有服务共用JWT_JWKS_URI、Redis会话和内部服务令牌。EVENT_OPERATIONS_TOKEN与业务令牌分离,仅三家事件生产服务注入;为空禁用运维操作。禁止把真实env提交到仓库。 +6. 本地Node合同、后端编译/测试及前端门禁完成后才能安排部署。Jenkins总控 backend-java,下游 baiyue-firstleg-,构建节点java-builder,唯一部署节点java-test,凭据firstleg-test-env。旧dev/prod Job必须停用或改回其独立受维护版本,不得指向此TEST流水线;不对既有实际生产环境做改名、删除或部署。 + +## 停机切换 + +停止验收与业务写入并记录当前容器、网络、配置、卷、会话和消息对象。逐项取得部署、SQL和删除授权;本清单不授权执行。 + +先关闭旧写入、Relay和消费进程,再应用审核通过的服务SQL基线、统一Nacos和Compose配置。不执行整机清空Redis/MQ/文件,不删除业务Schema。业务MQ继续保留:文件导出、通知、审计启用参数按现有业务核实,test-前缀隔离;可靠审计可显式叠加docker-compose.audit.yml。文件卷持续挂载/data/files。 + +启动原则:中间件健康 → IAM/Auth和公共能力 → 业务消费者/生产者 → Gateway → 四端。Compose不创建循环depends_on;对每个服务以镜像HEALTHCHECK及真实消费者探针确认就绪后才开放写入。所有服务关闭自动SQL初始化,SQL必须单独审核执行。 + +执行入口deploy-built-service.sh仅接受test与13个保留服务,必须在目标服务器本机执行(TARGET_IS_LOCAL=true);缺失关键环境参数、没有HEALTHCHECK、异常退出、健康超时均失败。deploy-services.sh仅顺序调用此入口,任何失败停止后续服务。构建和部署仍由明确授权启动。 + +统一登录、撤销、客户隔离、仓绑定、文件和资源查询通过后,新造装柜→尾程、资金指令→结果回传数据。完成正式契约全链路与故障恢复矩阵前不得宣称环境跑通。 + +旧海外网关/两区桥退出目标清单不代表已停机。只有直接消费、可靠重试/DEAD治理、认证撤销及四端切换验证通过,且确认无旧调用,才能按明确对象清单退役旧容器、注册和专用配置。禁止提前停止现有线上桥。 + +## 失败恢复 + +架构/Schema不匹配时默认停写、保留证据,修复后发布完整匹配新版本。确需恢复旧测试工作时,由人工按记录整体恢复旧镜像、四端、Nacos/Compose和旧Schema基线;不混跑两套协议。 + +单服务自动回滚默认关闭。仅确认前后版本协议和Schema完全一致时,才能在单服务Jenkins勾选ALLOW_SAME_CONTRACT_ROLLBACK;失败只允许恢复同仓库同服务前一镜像,回滚成功仍将Job判为失败。首次部署、旧镜像不属于该服务或旧新镜像相同均拒绝回滚。 + +## 本地验证入口 + +`node --test scripts/java-topology-contract.test.mjs scripts/overseas-deployment-contract.test.mjs scripts/deploy-built-service-rollback.test.mjs scripts/finance-deployment-contract.test.mjs scripts/audit-deployment-contract.test.mjs` + +PowerShell Nacos发布DryRun只列待发布项,不联网;Shell/Groovy离线合同不能替代真实Jenkins语法校验、Docker Compose渲染、镜像启动或HTTP验收。 + +四端协作确认:Admin/Client各自使用firstleg-test-admin-web与firstleg-test-client-web Compose项目,共接external firstleg-test-network;默认API_UPSTREAM=http://gateway:9000,页面仅绑定127.0.0.1:9001/9002。发布必须提供CLIENT_WEB_URL与VITE_ADMIN_WEB_ORIGIN,不能猜测运行域名。两个PDA保留独立产物,共用统一入口。 + +SQL初始化脚本只接受两份显式传入、已审核的空库baseline路径;未设置ALLOW_TEST_SCHEMA_INITIALIZATION=true与明确TEST_MYSQL_CONTAINER时拒绝。它不检查或创建授权,不允许用于已有数据环境,不自动清库,第一份失败立即停止。两份基线的当前维护入口为deploy/sql/README.md;实际SQL执行仍由独立授权覆盖。 diff --git a/deploy/docker-compose.audit.yml b/deploy/docker-compose.audit.yml index b0d29c2e9..87893998e 100644 --- a/deploy/docker-compose.audit.yml +++ b/deploy/docker-compose.audit.yml @@ -4,40 +4,40 @@ services: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}auth-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}auth-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} iam: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}iam-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}iam-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} order: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}order-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}order-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} firstleg: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}firstleg-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}firstleg-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} warehouse: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}warehouse-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}warehouse-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} audit-service: environment: ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} AUDIT_MQ_CONSUMER_ENABLED: "true" AUDIT_DLQ_CONSUMER_ENABLED: "true" AUDIT_DLQ_COMPENSATION_ENABLED: "true" diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml deleted file mode 100644 index 3082caf78..000000000 --- a/deploy/docker-compose.overseas.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: firstleg-${DEPLOY_ENV:-test}-overseas - -x-overseas-common: &overseas-common - restart: "on-failure:3" - stop_grace_period: ${STOP_GRACE_PERIOD:-45s} - networks: [firstleg-overseas-network, infra-network] - extra_hosts: - - "host.docker.internal:host-gateway" - environment: &overseas-environment - BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling} - BAIYUE_RELEASE_ID: ${RELEASE_ID:-local} - BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true} - SERVER_SHUTDOWN: graceful - SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s} - SPRING_PROFILES_ACTIVE: overseas - NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} - NACOS_USERNAME: ${OVERSEAS_NACOS_USERNAME} - NACOS_PASSWORD: ${OVERSEAS_NACOS_PASSWORD} - NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} - NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas} - REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal} - REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379} - REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-} - REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0} - INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} - JWT_ISSUER: ${JWT_ISSUER} - JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} - JWT_JWK_SET_URI: ${JWT_JWK_SET_URI} - REGION_CODE: OVERSEAS - DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas} - DATA_RESIDENCY: OVERSEAS - SPRING_SQL_INIT_MODE: never - SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} - TZ: Asia/Shanghai - logging: - # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 - driver: ${DOCKER_LOG_DRIVER:-json-file} - options: - max-size: ${DOCKER_LOG_MAX_SIZE:-50m} - max-file: "${DOCKER_LOG_MAX_FILE:-3}" - -services: - gateway-overseas: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PUBLISHED_PORT:-${GATEWAY_OVERSEAS_PORT:-9300}}:9300"] - environment: - <<: *overseas-environment - SERVER_PORT: 9300 - SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} - CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} - CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} - AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} - PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} - LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} - LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} - - lastleg-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-lastleg${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PUBLISHED_PORT:-${LASTLEG_PORT:-9303}}:9303"] - environment: - <<: *overseas-environment - SERVER_PORT: 9303 - LASTLEG_DB_URL: ${LASTLEG_DB_URL} - LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} - LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} - FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} - DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} - FILE_SERVICE_URL: ${FILE_SERVICE_URL} - # Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。 - PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} - WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} - - warehouse-overseas-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PUBLISHED_PORT:-${WAREHOUSE_OVERSEAS_PORT:-9305}}:9305"] - environment: - <<: *overseas-environment - SERVER_PORT: 9305 - WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} - WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} - WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} - DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} - # 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。 - PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} - - event-bridge-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-${EVENT_BRIDGE_OVERSEAS_PORT:-9313}}:9312"] - environment: - <<: *overseas-environment - SERVER_PORT: 9312 - EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL} - EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} - EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} - EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} - LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} - networks: - firstleg-overseas-network: - infra-network: - aliases: [event-bridge-overseas] - -networks: - firstleg-overseas-network: - name: firstleg-${DEPLOY_ENV:-test}-overseas-network - infra-network: - external: true - name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml index 7775ff3b9..45a047a6e 100644 --- a/deploy/docker-compose.rocketmq.yml +++ b/deploy/docker-compose.rocketmq.yml @@ -1,4 +1,4 @@ -name: firstleg-${DEPLOY_ENV:-test}-rocketmq +name: firstleg-test-rocketmq x-bounded-logging: &bounded-logging driver: ${DOCKER_LOG_DRIVER:-json-file} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a4c7dbdac..472f1e6a3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -name: firstleg-${DEPLOY_ENV:-test} +name: firstleg-test x-common: &common restart: "on-failure:3" @@ -8,31 +8,36 @@ x-common: &common extra_hosts: - "host.docker.internal:host-gateway" environment: &common-environment - DEPLOY_ENV: ${DEPLOY_ENV:-test} - BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling} + DEPLOY_ENV: test BAIYUE_RELEASE_ID: ${RELEASE_ID:-local} BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true} SERVER_SHUTDOWN: graceful SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s} - SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn} + SPRING_PROFILES_ACTIVE: test NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} NACOS_USERNAME: ${NACOS_USERNAME} NACOS_PASSWORD: ${NACOS_PASSWORD} NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} - NACOS_NAMESPACE: ${NACOS_NAMESPACE:-public} + NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual TEST namespace ID required} REDIS_HOST: ${REDIS_HOST} REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PASSWORD: ${REDIS_PASSWORD} + REDIS_USERNAME: ${REDIS_USERNAME:-default} + SPRING_DATA_REDIS_USERNAME: ${REDIS_USERNAME:-default} REDIS_DATABASE: ${REDIS_DATABASE:-0} - JWT_SECRET: ${JWT_SECRET} + AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate + JWT_ISSUER: ${JWT_ISSUER:-auth-service} + JWT_AUDIENCE: ${JWT_AUDIENCE:-lastleg-api} + JWT_JWKS_URI: http://auth:9100/api/auth/.well-known/jwks.json + REDIS_TOKEN_KEY_PREFIX: ${REDIS_TOKEN_KEY_PREFIX:-baiyue:auth} + SPRING_SQL_INIT_MODE: never + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER} + MQ_TOPIC_PREFIX: test- INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} - BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events} + BAIYUE_AUDIT_ENDPOINT: http://audit-service:9700/internal/audit/events SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false} AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false} - REGION_CODE: ${REGION_CODE:-CN} - DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn} - DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} TZ: Asia/Shanghai logging: # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 @@ -45,8 +50,8 @@ services: gateway: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-gateway${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"] + container_name: firstleg-test-gateway + ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"] environment: <<: *common-environment SERVER_PORT: 9000 @@ -57,46 +62,53 @@ services: auth: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-auth${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PUBLISHED_PORT:-${AUTH_PORT:-9100}}:9100"] + container_name: firstleg-test-auth + expose: ["9100"] environment: <<: *common-environment SERVER_PORT: 9100 + JWT_RSA_JWK_SET: ${JWT_RSA_JWK_SET} + JWT_CURRENT_KID: ${JWT_CURRENT_KID} AUTH_DB_URL: ${AUTH_DB_URL} AUTH_DB_USERNAME: ${AUTH_DB_USERNAME} AUTH_DB_PASSWORD: ${AUTH_DB_PASSWORD} - OVERSEAS_JWT_ENABLED: ${OVERSEAS_JWT_ENABLED:-false} - OVERSEAS_JWT_ISSUER: ${OVERSEAS_JWT_ISSUER:-auth-service} - OVERSEAS_JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} - OVERSEAS_JWT_RSA_JWK_SET: ${OVERSEAS_JWT_RSA_JWK_SET:-} - OVERSEAS_JWT_CURRENT_KID: ${OVERSEAS_JWT_CURRENT_KID:-} - OVERSEAS_ACCESS_TOKEN_TTL: ${OVERSEAS_ACCESS_TOKEN_TTL:-15m} iam: <<: *common image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-iam${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PUBLISHED_PORT:-${IAM_PORT:-9201}}:9201"] + container_name: firstleg-test-iam + expose: ["9201"] environment: <<: *common-environment SERVER_PORT: 9201 + NOTIFICATION_SCHEDULER_ENABLED: ${NOTIFICATION_SCHEDULER_ENABLED:-false} + NOTIFICATION_MQ_CONSUMER_ENABLED: ${NOTIFICATION_MQ_CONSUMER_ENABLED:-false} IAM_DB_URL: ${IAM_DB_URL} IAM_DB_USERNAME: ${IAM_DB_USERNAME} IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} - SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} firstleg: <<: *common image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-business${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PUBLISHED_PORT:-${FIRSTLEG_PORT:-9202}}:9202"] + container_name: firstleg-test-business + expose: ["9202"] environment: <<: *common-environment SERVER_PORT: 9202 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL} FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} - LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL} MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} @@ -121,21 +133,31 @@ services: finance: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-finance${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PUBLISHED_PORT:-${FINANCE_PORT:-9203}}:9203"] + container_name: firstleg-test-finance + expose: ["9203"] environment: <<: *common-environment SERVER_PORT: 9203 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 FINANCE_DB_URL: ${FINANCE_DB_URL} FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} - OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} order: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-order${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PUBLISHED_PORT:-${ORDER_PORT:-9204}}:9204"] + container_name: firstleg-test-order + expose: ["9204"] environment: <<: *common-environment SERVER_PORT: 9204 @@ -146,8 +168,8 @@ services: warehouse: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-warehouse${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PUBLISHED_PORT:-${WAREHOUSE_PORT:-9205}}:9205"] + container_name: firstleg-test-warehouse + expose: ["9205"] environment: <<: *common-environment SERVER_PORT: 9205 @@ -158,8 +180,8 @@ services: file: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-file${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PUBLISHED_PORT:-${FILE_PORT:-9206}}:9206"] + container_name: firstleg-test-file + expose: ["9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" environment: @@ -172,8 +194,6 @@ services: PDA_UPDATE_PUBLIC_BASE_URL: ${PDA_UPDATE_PUBLIC_BASE_URL:-} JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" # Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。 - ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-} - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false} FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4} FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false} @@ -182,8 +202,8 @@ services: print: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-print${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PUBLISHED_PORT:-${PRINT_PORT:-9207}}:9207"] + container_name: firstleg-test-print + expose: ["9207"] environment: <<: *common-environment SERVER_PORT: 9207 @@ -192,8 +212,8 @@ services: integration-service: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-integration${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PUBLISHED_PORT:-${INTEGRATION_PORT:-9210}}:9210"] + container_name: firstleg-test-integration + expose: ["9210"] environment: <<: *common-environment SERVER_PORT: 9210 @@ -238,31 +258,10 @@ services: HEDE_TRACKING_REQUEST_TIMEOUT: ${HEDE_TRACKING_REQUEST_TIMEOUT:-30s} HEDE_TRACKING_MIN_REQUEST_INTERVAL: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:-5s} - event-bridge-service: - <<: *common - image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PUBLISHED_PORT:-${EVENT_BRIDGE_PORT:-9312}}:9312"] - environment: - <<: *common-environment - SERVER_PORT: 9312 - EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL} - EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} - EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} - EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} - FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} - FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events} - FINANCE_WALLET_EVENT_CONSUMER_URL: ${FINANCE_WALLET_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/wallet-command-events} - SPRING_SQL_INIT_MODE: never - networks: - firstleg-network: - infra-network: - aliases: [event-bridge-cn] - audit-service: <<: *common image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-audit${DEPLOY_SLOT_SUFFIX:-} + container_name: firstleg-test-audit expose: ["9700"] environment: <<: *common-environment @@ -274,9 +273,50 @@ services: AUDIT_DLQ_CONSUMER_ENABLED: ${AUDIT_DLQ_CONSUMER_ENABLED:-false} AUDIT_DLQ_COMPENSATION_ENABLED: ${AUDIT_DLQ_COMPENSATION_ENABLED:-false} + lastleg-service: + <<: *common + image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} + container_name: firstleg-test-lastleg + expose: ["9303"] + environment: + <<: *common-environment + SERVER_PORT: 9303 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 + LASTLEG_DB_URL: ${LASTLEG_DB_URL} + LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} + LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL:-http://warehouse:9205} + FILE_SERVICE_URL: ${FILE_SERVICE_URL:-http://file:9206} + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} + + warehouse-overseas-service: + <<: *common + image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} + container_name: firstleg-test-warehouse-overseas + expose: ["9305"] + environment: + <<: *common-environment + SERVER_PORT: 9305 + WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} + WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} + WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + networks: firstleg-network: - name: firstleg-${DEPLOY_ENV:-test}-network + name: firstleg-test-network infra-network: external: true name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index ad239b1bf..5826218bd 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine +ARG BASE_IMAGE=eclipse-temurin:17-jre-alpine FROM ${BASE_IMAGE} AS extractor WORKDIR /workspace diff --git a/deploy/env/.env.infra-test.example b/deploy/env/.env.infra-test.example index 583f4d3ec..c80956a07 100644 --- a/deploy/env/.env.infra-test.example +++ b/deploy/env/.env.infra-test.example @@ -7,7 +7,7 @@ MYSQL_PASSWORD=CHANGE_ME_TEST_MYSQL_PASSWORD REDIS_PASSWORD=CHANGE_ME_TEST_REDIS_PASSWORD # RocketMQ 与 File Service 位于同一 Docker 网络,Broker 向客户端公布服务名。 ROCKETMQ_BROKER_IP=rocketmq-broker -# 测试环境必须显式使用 test-;生产环境在对应凭据文件中显式设置为空字符串。 +# 测试业务 MQ topic/group 使用 test- 前缀。 MQ_TOPIC_PREFIX=test- AUDIT_OUTBOX_ENABLED=false AUDIT_OUTBOX_PUBLISHER_ENABLED=false diff --git a/deploy/env/.env.test.example b/deploy/env/.env.test.example new file mode 100644 index 000000000..13dcc01d5 --- /dev/null +++ b/deploy/env/.env.test.example @@ -0,0 +1,54 @@ +# TEST only. Copy outside the repository and supply secrets securely. +NACOS_SERVER_ADDR=host.docker.internal:8848 +NACOS_NAMESPACE= +NACOS_USERNAME= +NACOS_PASSWORD= +REDIS_HOST=host.docker.internal +REDIS_PORT=6379 +REDIS_USERNAME=default +REDIS_DATABASE=0 +REDIS_TOKEN_KEY_PREFIX=baiyue:auth +ROCKETMQ_NAME_SERVER=rocketmq-namesrv:9876 +MQ_TOPIC_PREFIX=test- +JWT_ISSUER=auth-service +JWT_AUDIENCE=lastleg-api +JWT_RSA_JWK_SET= +JWT_CURRENT_KID= +EVENT_OPERATIONS_TOKEN= +INFRA_NETWORK=firstleg-test-infra-network +IMAGE_REPOSITORY=registry.example.com/team/baiyue-firstleg +IMAGE_TAG= +FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads +CORS_ALLOWED_ORIGINS= +INTERNAL_SERVICE_TOKEN= +REDIS_PASSWORD= +AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +AUTH_DB_USERNAME= +AUTH_DB_PASSWORD= +IAM_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +IAM_DB_USERNAME= +IAM_DB_PASSWORD= +FIRSTLEG_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FIRSTLEG_DB_USERNAME= +FIRSTLEG_DB_PASSWORD= +FINANCE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FINANCE_DB_USERNAME= +FINANCE_DB_PASSWORD= +ORDER_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +ORDER_DB_USERNAME= +ORDER_DB_PASSWORD= +WAREHOUSE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +WAREHOUSE_DB_USERNAME= +WAREHOUSE_DB_PASSWORD= +FILE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FILE_DB_USERNAME= +FILE_DB_PASSWORD= +AUDIT_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +AUDIT_DB_USERNAME= +AUDIT_DB_PASSWORD= +LASTLEG_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +LASTLEG_DB_USERNAME= +LASTLEG_DB_PASSWORD= +WAREHOUSE_OVERSEAS_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +WAREHOUSE_OVERSEAS_DB_USERNAME= +WAREHOUSE_OVERSEAS_DB_PASSWORD= diff --git a/deploy/scripts/deploy-blue-green-service.sh b/deploy/scripts/deploy-blue-green-service.sh deleted file mode 100644 index ff2fb20ee..000000000 --- a/deploy/scripts/deploy-blue-green-service.sh +++ /dev/null @@ -1,411 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -ne 7 ]; then - echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件> <项目名>" >&2 - exit 2 -fi - -DEPLOY_ENV="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -SERVICE="$4" -ENV_FILE="$5" -COMPOSE_FILE="$6" -BASE_PROJECT="$7" - -case "$DEPLOY_ENV" in - test|production) ;; - *) echo "蓝绿执行器仅用于 test/production,当前: $DEPLOY_ENV" >&2; exit 2 ;; -esac - -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - [ -f "$file" ] || { echo "必需文件不存在: $file" >&2; exit 2; } -done - -case "$SERVICE" in - *[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2; exit 2 ;; -esac - -env_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -service_contract() { - case "$SERVICE:$COMPOSE_FILE" in - gateway:*) echo "gateway|9000|9000|gateway|gateway-active" ;; - auth:*) echo "auth|9100|9100|auth-service|auth" ;; - iam:*) echo "iam|9201|9201|iam-service|iam" ;; - firstleg:*) echo "business|9202|9202|firstleg-service|firstleg" ;; - finance:*) echo "finance|9203|9203|finance-service|finance" ;; - order:*) echo "order|9204|9204|order-service|order" ;; - warehouse:*) echo "warehouse|9205|9205|warehouse-service|warehouse" ;; - file:*) echo "file|9206|9206|file-service|file" ;; - print:*) echo "print|9207|9207|print-service|print" ;; - integration-service:*) echo "integration|9210|9210|integration-service|integration-service" ;; - audit-service:*) echo "audit|9700|9700|audit-service|audit-service" ;; - gateway-overseas:*) echo "gateway-overseas|9300|9300|gateway-overseas|gateway-overseas-active" ;; - lastleg-service:*) echo "lastleg|9303|9303|lastleg-service|lastleg-service" ;; - warehouse-overseas-service:*) echo "warehouse-overseas|9305|9305|warehouse-overseas-service|warehouse-overseas-service" ;; - event-bridge-service:*overseas*) echo "event-bridge-overseas|9312|9313|event-bridge-overseas|event-bridge-overseas" ;; - event-bridge-service:*) echo "event-bridge|9312|9312|event-bridge-cn|event-bridge-cn" ;; - *) echo "不支持蓝绿部署的服务: $SERVICE" >&2; exit 2 ;; - esac -} - -IFS='|' read -r CONTAINER_STEM INTERNAL_PORT BASE_PORT NACOS_SERVICE ROUTING_ALIAS <&2; exit 2 ;; esac -done - -mkdir -p "$STATE_ROOT" -chmod 750 "$STATE_ROOT" -docker network inspect "$ROUTING_NETWORK" >/dev/null 2>&1 || docker network create "$ROUTING_NETWORK" >/dev/null - -legacy_container="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}" -active_slot="" -active_container="" -active_port="" -active_worker="" -if [ -s "$STATE_FILE" ]; then - IFS='|' read -r active_slot active_container active_port active_image active_worker < "$STATE_FILE" -elif docker inspect "$legacy_container" >/dev/null 2>&1; then - active_slot="legacy" - active_container="$legacy_container" - active_port="$BASE_PORT" -fi - -if [ "$active_slot" = "blue" ]; then - target_slot="green" -else - target_slot="blue" - [ "$active_slot" = "legacy" ] && target_slot="green" -fi - -if [ "$target_slot" = "blue" ]; then - target_port=$((BASE_PORT + 20000)) -else - target_port=$((BASE_PORT + 10000)) -fi -candidate="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}-${target_slot}" -worker_candidate="${candidate}-worker" - -if docker inspect "$candidate" >/dev/null 2>&1; then - if [ "$candidate" = "$active_container" ]; then - echo "活动槽状态异常,候选容器与活动容器相同: $candidate" >&2 - exit 1 - fi - docker rm -f "$candidate" >/dev/null -fi - -compose() { - docker compose --project-name "$BASE_PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -container_health() { - container="$1" - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ - "$container" 2>/dev/null || echo missing -} - -cleanup_candidate() { - docker rm -f "$candidate" >/dev/null 2>&1 || true -} - -wait_for_container() { - container="$1" - label="$2" - elapsed=0 - while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do - state="$(container_health "$container")" - echo "$label 健康状态: $state(已等待 ${elapsed}s)" - case "$state" in - healthy) return 0 ;; - unhealthy|exited|dead|missing) return 1 ;; - esac - sleep "$HEALTH_POLL" - elapsed=$((elapsed + HEALTH_POLL)) - done - return 1 -} - -needs_worker() { - case "$SERVICE" in - iam|firstleg|finance|order|event-bridge-service|lastleg-service) return 0 ;; - file) - [ "$(env_value FILE_EXPORT_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value FILE_EXPORT_PUBLISHER_ENABLED)" = true ] \ - || [ "$(env_value FILE_EXPORT_RECONCILER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ] - ;; - audit-service) - [ "$(env_value AUDIT_MQ_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_DLQ_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_DLQ_COMPENSATION_ENABLED)" = true ] - ;; - auth|warehouse|warehouse-overseas-service) - [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ] - ;; - *) return 1 ;; - esac -} - -start_worker_candidate() { - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - echo "启动单例 Worker: $worker_candidate" - if [ "$SERVICE" = file ]; then - worker_java_options="-Xms512m -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" - else - worker_java_options="-Xms256m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" - fi - if ! compose run -d --no-deps --name "$worker_candidate" \ - -e "NACOS_REGISTER_ENABLED=false" \ - -e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=true" \ - -e "BAIYUE_DEPLOY_SLOT=${target_slot}-worker" \ - -e "JAVA_TOOL_OPTIONS=$worker_java_options" \ - -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" "$SERVICE" >/dev/null; then - return 1 - fi - docker network connect "$ROUTING_NETWORK" "$worker_candidate" - if ! wait_for_container "$worker_candidate" "候选 Worker"; then - docker logs --tail 200 "$worker_candidate" 2>&1 || true - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - return 1 - fi - worker_image="$(docker inspect --format '{{.Config.Image}}' "$worker_candidate")" - [ "$worker_image" = "$IMAGE" ] || return 1 -} - -connect_routing_aliases() { - container="$1" - shift - docker network inspect "$ROUTING_NETWORK" --format '{{json .Containers}}' | grep -q "\"$container\"" \ - && return 0 - set -- docker network connect $(for alias_name in "$@"; do printf '%s\n' --alias "$alias_name"; done) - "$@" "$ROUTING_NETWORK" "$container" -} - -bootstrap_legacy_routes() { - for pair in \ - gateway-active,gateway:gateway auth,auth-service:auth iam,iam-service:iam \ - firstleg,firstleg-service:business finance,finance-service:finance order,order-service:order \ - warehouse,warehouse-service:warehouse file,file-service:file print,print-service:print \ - integration-service:integration audit-service:audit \ - gateway-overseas-active,gateway-overseas:gateway-overseas \ - lastleg-service:lastleg warehouse-overseas-service:warehouse-overseas \ - event-bridge-cn:event-bridge event-bridge-overseas:event-bridge-overseas - do - aliases="${pair%%:*}" - stem="${pair#*:}" - container="firstleg-${DEPLOY_ENV}-${stem}" - if docker inspect "$container" >/dev/null 2>&1; then - connect_routing_aliases "$container" $(printf '%s' "$aliases" | tr ',' ' ') || true - fi - done - for container in firstleg-prod-admin-web firstleg-prod-client-web; do - if docker inspect "$container" >/dev/null 2>&1; then - connect_routing_aliases "$container" "${container##*-}" || true - fi - done -} - -switch_gateway_proxy() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - if [ "$DEPLOY_ENV" = test ]; then - echo "测试 Gateway 仍由固定入口承载,不在后端蓝绿执行器中切换" >&2 - return 1 - fi - if [ "$SERVICE" = "gateway" ]; then required_alias=gateway-active; else required_alias=gateway-overseas-active; fi - for frontend_service in admin-web client-web; do - frontend_state="$STATE_ROOT/frontend-$frontend_service.active" - if [ ! -s "$frontend_state" ]; then - echo "$frontend_service 尚未完成首次蓝绿迁移,拒绝切换 Gateway" >&2 - return 1 - fi - IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state" - if ! docker inspect "$frontend" >/dev/null 2>&1 \ - || ! docker exec "$frontend" nginx -T 2>&1 | grep -q "$required_alias"; then - echo "$frontend 尚未使用稳定上游 $required_alias,拒绝切换 Gateway" >&2 - return 1 - fi - done - route_root="${BLUE_GREEN_ROUTE_CONFIG_ROOT:-/www/server/panel/vhost/nginx}" - if [ "$SERVICE" = "gateway" ]; then - config="$route_root/00-blue-green-gateway.conf" - else - config="$route_root/00-blue-green-gateway-overseas.conf" - fi - if [ -z "$config" ] || [ ! -f "$config" ]; then - echo "$SERVICE 蓝绿切换要求环境文件配置有效的 Nginx 配置路径" >&2 - return 1 - fi - backup="$config.blue-green-backup" - cp -f "$config" "$backup" - if ! sed -i "s#127\.0\.0\.1:${active_port:-$BASE_PORT}#127.0.0.1:$target_port#g" "$config" \ - || cmp -s "$config" "$backup"; then - cp -f "$backup" "$config" - echo "未能在 Nginx 配置中切换 $SERVICE 上游端口" >&2 - return 1 - fi - if ! nginx -t || ! nginx -s reload; then - cp -f "$backup" "$config" - nginx -t && nginx -s reload || true - return 1 - fi - rm -f "$backup" -} - -reload_frontend_proxies() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - [ "$DEPLOY_ENV" = production ] || return 0 - for frontend_service in admin-web client-web; do - frontend_state="$STATE_ROOT/frontend-$frontend_service.active" - [ -s "$frontend_state" ] || return 1 - IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state" - docker inspect "$frontend" >/dev/null 2>&1 || return 1 - docker exec "$frontend" nginx -t - docker exec "$frontend" nginx -s reload - done -} - -rollback_gateway_proxy() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - [ "$DEPLOY_ENV" = production ] || return 0 - route_root="${BLUE_GREEN_ROUTE_CONFIG_ROOT:-/www/server/panel/vhost/nginx}" - if [ "$SERVICE" = "gateway" ]; then - config="$route_root/00-blue-green-gateway.conf" - else - config="$route_root/00-blue-green-gateway-overseas.conf" - fi - old_port="${active_port:-$BASE_PORT}" - sed -i "s#127\.0\.0\.1:$target_port#127.0.0.1:$old_port#g" "$config" - nginx -t && nginx -s reload -} - -bootstrap_legacy_routes - -export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG -echo "启动候选槽: service=$SERVICE slot=$target_slot port=$target_port image=$IMAGE" -if ! compose run -d --no-deps --name "$candidate" \ - -e "BAIYUE_DEPLOY_SLOT=$target_slot" -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" \ - -e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=false" \ - -e "AUDIT_OUTBOX_PUBLISHER_ENABLED=false" \ - -e "NOTIFICATION_SCHEDULER_ENABLED=false" \ - -e "NOTIFICATION_MQ_CONSUMER_ENABLED=false" \ - -e "FILE_EXPORT_CONSUMER_ENABLED=false" \ - -e "FILE_EXPORT_PUBLISHER_ENABLED=false" \ - -e "FILE_EXPORT_RECONCILER_ENABLED=false" \ - -e "AUDIT_MQ_CONSUMER_ENABLED=false" \ - -e "AUDIT_DLQ_CONSUMER_ENABLED=false" \ - -e "AUDIT_DLQ_COMPENSATION_ENABLED=false" \ - -p "${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:$target_port:$INTERNAL_PORT" "$SERVICE" >/dev/null; then - cleanup_candidate - exit 1 -fi - -if ! wait_for_container "$candidate" "候选 Web 槽 $target_slot"; then - echo "候选槽未通过健康检查,保留旧实例" >&2 - docker logs --tail 200 "$candidate" 2>&1 || true - cleanup_candidate - exit 1 -fi - -running_image="$(docker inspect --format '{{.Config.Image}}' "$candidate")" -if [ "$running_image" != "$IMAGE" ]; then - echo "候选镜像不一致: expected=$IMAGE actual=$running_image" >&2 - cleanup_candidate - exit 1 -fi - -if [ "$NACOS_SERVICE" = "$ROUTING_ALIAS" ]; then - connect_routing_aliases "$candidate" "$ROUTING_ALIAS" -else - connect_routing_aliases "$candidate" "$ROUTING_ALIAS" "$NACOS_SERVICE" -fi - -if [ "$STABILIZATION_SECONDS" -gt 0 ]; then - echo "候选槽稳定观察 ${STABILIZATION_SECONDS}s" - sleep "$STABILIZATION_SECONDS" - [ "$(container_health "$candidate")" = "healthy" ] || { cleanup_candidate; exit 1; } -fi - -if ! switch_gateway_proxy; then - cleanup_candidate - exit 1 -fi - -worker_required=false -if needs_worker; then worker_required=true; fi - -if [ "$worker_required" = true ] && [ "$active_slot" != legacy ]; then - if [ -n "$active_worker" ] && docker inspect "$active_worker" >/dev/null 2>&1; then - echo "暂停旧 Worker,避免新旧任务并行: $active_worker" - docker stop --time "$STOP_TIMEOUT" "$active_worker" >/dev/null - fi - if ! start_worker_candidate; then - [ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true - rollback_gateway_proxy || true - cleanup_candidate - exit 1 - fi -fi - -old_container_stopped=false -if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then - if [ "$DRAIN_SECONDS" -gt 0 ]; then - echo "蓝绿切换后连接排空 ${DRAIN_SECONDS}s,旧 Web 实例保持运行: $active_container" - sleep "$DRAIN_SECONDS" - fi - echo "优雅停止旧 Web 实例: $active_container" - docker stop --time "$STOP_TIMEOUT" "$active_container" >/dev/null - old_container_stopped=true -fi - -if [ "$worker_required" = true ] && [ "$active_slot" = legacy ]; then - if ! start_worker_candidate; then - [ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true - rollback_gateway_proxy || true - cleanup_candidate - exit 1 - fi -fi - -if ! reload_frontend_proxies; then - [ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true - if [ "$worker_required" = true ]; then - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - [ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true - fi - rollback_gateway_proxy || true - cleanup_candidate - reload_frontend_proxies || true - exit 1 -fi - -[ -z "$active_worker" ] || [ "$active_worker" = "$worker_candidate" ] \ - || docker rm "$active_worker" >/dev/null 2>&1 || true -[ "$old_container_stopped" = false ] || docker rm "$active_container" >/dev/null - -if [ "$worker_required" = true ]; then - next_worker="$worker_candidate" -else - next_worker="" -fi -printf '%s|%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$IMAGE" "$next_worker" > "$STATE_FILE.tmp" -chmod 640 "$STATE_FILE.tmp" -mv -f "$STATE_FILE.tmp" "$STATE_FILE" -echo "蓝绿切换完成: service=$SERVICE active=$target_slot container=$candidate port=$target_port" diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index a658d1e77..3846f5b8d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -28,177 +28,45 @@ for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do esac done -case "$DEPLOY_ENV" in - development|test|production) ;; - *) - echo "仅支持 development/test/production,当前: $DEPLOY_ENV" >&2 - exit 2 - ;; -esac - -if [ "$DEPLOY_ENV" = "development" ]; then - PUBLISHED_BIND_ADDRESS=127.0.0.1 -else - PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" -fi -export PUBLISHED_BIND_ADDRESS +[ "$DEPLOY_ENV" = "test" ] || { echo "Only test is supported" >&2; exit 2; } case "$SERVICE" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $SERVICE" >&2 - exit 2 - ;; + gateway|auth|iam|firstleg|finance|order|warehouse|file|print|integration-service|lastleg-service|warehouse-overseas-service|audit-service) ;; + *) echo "Unknown or retired service: $SERVICE" >&2; exit 2 ;; esac - -if [ ! -f "$ENV_FILE" ]; then - echo "必需文件不存在: $ENV_FILE" >&2 - exit 2 -fi - -env_file_has_key() { - grep -q "^$1=" "$ENV_FILE" -} - -env_file_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" - ;; - event-bridge-service) - BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" - if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then - BRIDGE_REGION="$(env_file_value REGION_CODE)" - fi - if [ -z "$BRIDGE_REGION" ]; then - echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 - exit 2 - fi - case "$BRIDGE_REGION" in - CN) ;; - OVERSEAS) - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" - ;; - *) - echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2 - exit 2 - ;; - esac - ;; -esac - -if [ "$SERVICE" = "gateway-overseas" ]; then - if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then - echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 - exit 2 - fi -fi - -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - if [ ! -f "$file" ]; then - echo "必需文件不存在: $file" >&2 - exit 2 - fi +[ "${TARGET_IS_LOCAL:-false}" = "true" ] || { echo "TEST deploy must run on the target host" >&2; exit 2; } +[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; } +env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } +for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done - -if [ "$SERVICE" = "finance" ]; then - for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi +[ "$(env_file_value MQ_TOPIC_PREFIX)" = "test-" ] || { echo "MQ_TOPIC_PREFIX must be test-" >&2; exit 2; } +case "$SERVICE" in + gateway|print|integration-service) db_prefix='' ;; + lastleg-service) db_prefix=LASTLEG ;; + warehouse-overseas-service) db_prefix=WAREHOUSE_OVERSEAS ;; + audit-service) db_prefix=AUDIT ;; + *) db_prefix=$(printf '%s' "$SERVICE" | tr '[:lower:]' '[:upper:]') ;; +esac +if [ -n "$db_prefix" ]; then + for suffix in URL USERNAME PASSWORD; do + key="${db_prefix}_DB_${suffix}" + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done fi - -if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then - for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署海外服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "firstleg" ]; then - if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then - echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 - exit 2 - fi - if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then - for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - fi -fi - -if [ "$SERVICE" = "lastleg-service" ]; then - for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi +if [ "$SERVICE" = "firstleg" ] && [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then + for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done fi - -if [ "$SERVICE" = "warehouse-overseas-service" ]; then - for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "event-bridge-service" ]; then - if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" - else - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" - fi - for key in $EVENT_BRIDGE_KEYS; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "file" ]; then - if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then - echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 - exit 2 - fi - if ! env_file_has_key MQ_TOPIC_PREFIX; then - echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX" >&2 - exit 2 - fi - MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) - if [ "$DEPLOY_ENV" = "test" ] && [ "$MQ_PREFIX" != "test-" ]; then - echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 - exit 2 - fi - if [ "$DEPLOY_ENV" = "development" ] && { [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; }; then - echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2 - exit 2 - fi - if [ "$DEPLOY_ENV" = "production" ] && [ -n "$MQ_PREFIX" ]; then - echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为空,使用正式 Topic" >&2 - exit 2 - fi -fi - +# Architecture/schema changes must use whole-release recovery, never an implicit old image. +export PUBLISHED_BIND_ADDRESS=127.0.0.1 : "${REGISTRY_USER:?REGISTRY_USER 未设置}" : "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" local_registry_logout() { docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true + if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi } local_compose() { @@ -255,8 +123,8 @@ wait_for_local_health() { return 0 ;; no-health:running) - echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2 - return 0 + echo "Missing HEALTHCHECK; deployment failed" >&2 + return 1 ;; unhealthy|no-health:exited|no-health:dead) return 1 @@ -270,6 +138,10 @@ wait_for_local_health() { } deploy_local() { + deploy_docker_config=$(mktemp -d) + DOCKER_CONFIG="$deploy_docker_config" + export DOCKER_CONFIG + trap local_registry_logout EXIT HUP INT TERM printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" trap local_registry_logout EXIT HUP INT TERM previous_image="$(local_container_image || true)" @@ -306,6 +178,11 @@ deploy_local() { else echo "新镜像容器不存在,无法读取启动日志" >&2 fi + if [ "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" != "true" ]; then + echo "Automatic rollback disabled: keep writes closed and recover the matched release" >&2 + local_compose stop "$SERVICE" >/dev/null 2>&1 || true + return 1 + fi expected_prefix="$IMAGE_REPOSITORY/$SERVICE:" case "$previous_image" in "$expected_prefix"*) @@ -346,241 +223,4 @@ deploy_local() { return 1 } -if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then - echo "镜像已在目标服务器本机,直接部署: $IMAGE" - if [ "$DEPLOY_ENV" = "production" ]; then - printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" - trap local_registry_logout EXIT HUP INT TERM - docker pull "$IMAGE" - sh deploy/scripts/deploy-blue-green-service.sh \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME" - local_registry_logout - trap - EXIT HUP INT TERM - exit 0 - fi - deploy_local - exit 0 -fi - -SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" -SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" -SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" -REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" - -for file in "$SSH_KEY" "$SSH_KNOWN_HOSTS"; do - if [ ! -f "$file" ]; then - echo "SSH 文件不存在: $file" >&2 - exit 2 - fi -done - -run_ssh() { - ssh -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o Compression=no \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o Compression=no \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$@" -} - -run_ssh install -d -m 750 "$REMOTE_DIR" - -SUFFIX="${IMAGE_TAG}.$$.${SERVICE}" -REMOTE_ENV_NAME=".env.$PROJECT_NAME" -REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml" -REMOTE_BLUE_GREEN_NAME="deploy-blue-green-service.sh" -REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$SUFFIX.tmp" -REMOTE_BLUE_GREEN_TMP="$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME.$SUFFIX.tmp" -run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" -run_scp "deploy/scripts/deploy-blue-green-service.sh" "$SSH_TARGET:$REMOTE_BLUE_GREEN_TMP" -run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_BLUE_GREEN_TMP" \ - "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" <<'REMOTE_PREPARE' -set -eu -env_tmp="$1" -compose_tmp="$2" -blue_green_tmp="$3" -env_target="$4" -compose_target="$5" -blue_green_target="$6" -chmod 600 "$env_tmp" -chmod 644 "$compose_tmp" -chmod 750 "$blue_green_tmp" -mv -f "$env_tmp" "$env_target" -mv -f "$compose_tmp" "$compose_target" -mv -f "$blue_green_tmp" "$blue_green_target" -REMOTE_PREPARE - -remote_registry_logout() { - run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true -} - -echo "目标服务器从共享仓库拉取增量镜像: $IMAGE" -printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" -trap remote_registry_logout EXIT HUP INT TERM -run_ssh docker pull "$IMAGE" - -if [ "$DEPLOY_ENV" = "production" ]; then - echo "$DEPLOY_ENV 服务使用蓝绿执行器,旧实例在候选槽健康前保持运行" - run_ssh sh "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" \ - "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$PROJECT_NAME" - remote_registry_logout - trap - EXIT HUP INT TERM - exit 0 -fi - -run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" "$HEALTH_TIMEOUT_SECONDS" "$HEALTH_POLL_SECONDS" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -deploy_env="$2" -image_repository="$3" -image_tag="$4" -service="$5" -project_name="$6" -env_file="$7" -compose_file="$8" -health_timeout_seconds="$9" -health_poll_seconds="${10}" -cd "$remote_dir" -export DEPLOY_ENV="$deploy_env" -export IMAGE_REPOSITORY="$image_repository" -export IMAGE_TAG="$image_tag" -if [ "$deploy_env" = "development" ]; then - export PUBLISHED_BIND_ADDRESS=127.0.0.1 -else - export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" -fi - -compose() { - docker compose \ - --project-name "$project_name" \ - --env-file "$env_file" \ - -f "$compose_file" \ - "$@" -} - -container_id() { - compose ps --all --quiet "$service" | head -n 1 -} - -container_image() { - id="$(container_id)" - if [ -n "$id" ]; then - docker inspect --format '{{.Config.Image}}' "$id" - fi -} - -health_state() { - id="$(container_id)" - if [ -z "$id" ]; then - echo missing - return - fi - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$id" 2>/dev/null || echo missing -} - -wait_for_health() { - elapsed=0 - while [ "$elapsed" -lt "$health_timeout_seconds" ]; do - state="$(health_state)" - echo "容器健康状态: $state(已等待 ${elapsed}s)" - case "$state" in - healthy) return 0 ;; - no-health:running) - echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2 - return 0 - ;; - unhealthy|no-health:exited|no-health:dead) return 1 ;; - esac - sleep "$health_poll_seconds" - elapsed=$((elapsed + health_poll_seconds)) - done - echo "等待容器健康超时: ${health_timeout_seconds}s" >&2 - return 1 -} - -new_image="$image_repository/$service:$image_tag" -previous_image="$(container_image || true)" -if [ -n "$previous_image" ]; then - echo "部署前运行镜像: $previous_image" -else - echo "首次部署,未发现可回滚的旧镜像" -fi - -new_started=true -if ! compose up -d --no-deps --pull never "$service"; then - echo "新镜像启动命令失败: $new_image" >&2 - new_started=false -fi -if [ "$new_started" = "true" ] && wait_for_health; then - running_image="$(container_image || true)" - if [ "$running_image" != "$new_image" ]; then - echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2 - exit 1 - fi - echo "新镜像健康检查通过: $new_image" - compose ps "$service" - exit 0 -fi - -echo "新镜像健康检查失败: $new_image" >&2 -failed_container_id="$(container_id || true)" -if [ -n "$failed_container_id" ]; then - echo "新镜像容器最近日志(最多 200 行):" >&2 - docker logs --tail 200 "$failed_container_id" 2>&1 || true -else - echo "新镜像容器不存在,无法读取启动日志" >&2 -fi -expected_prefix="$image_repository/$service:" -case "$previous_image" in - "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; - '') - echo "首次部署没有可回滚镜像,部署失败" >&2 - compose stop "$service" >/dev/null 2>&1 || true - exit 1 - ;; - *) - echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2 - exit 1 - ;; -esac -if [ "$previous_image" = "$new_image" ]; then - echo "旧镜像与新镜像相同,无法自动回滚: $new_image" >&2 - exit 1 -fi - -echo "开始自动回滚到: $previous_image" >&2 -if ! docker pull "$previous_image"; then - echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2 - exit 1 -fi -IMAGE_TAG="$previous_tag" -export IMAGE_TAG -if ! compose up -d --no-deps --pull never "$service"; then - echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2 - exit 1 -fi -if wait_for_health; then - echo "自动回滚成功: $previous_image" >&2 - compose ps "$service" || true -else - echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2 -fi -exit 1 -REMOTE_DEPLOY - -remote_registry_logout -trap - EXIT HUP INT TERM +deploy_local diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh deleted file mode 100644 index 23343f7a4..000000000 --- a/deploy/scripts/deploy-development-service.sh +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -ne 4 ]; then - echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 - exit 2 -fi - -IMAGE_REPOSITORY="$1" -IMAGE_TAG="$2" -SERVICE_CSV="$3" -ENV_FILE="$4" - -SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" -SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" -SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}" -REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" -COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}" -PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}" - -if [ ! -f "$SSH_KEY" ]; then - echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 - exit 2 -fi -if [ ! -f "$SSH_KNOWN_HOSTS" ]; then - echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2 - exit 2 -fi -if [ ! -f "$ENV_FILE" ]; then - echo "开发环境变量文件不存在: $ENV_FILE" >&2 - exit 2 -fi -if [ ! -f "$COMPOSE_FILE" ]; then - echo "Compose 文件不存在: $COMPOSE_FILE" >&2 - exit 2 -fi - -SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') -for service in $SERVICES; do - case "$service" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $service" >&2 - exit 2 - ;; - esac -done - -if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then - echo "开发镜像已在开发节点本机构建,直接部署: $SERVICE_CSV" - install -d -m 750 "$REMOTE_DIR" - - SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" - LOCAL_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" - LOCAL_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" - install -m 600 "$ENV_FILE" "$LOCAL_ENV_TMP" - install -m 644 "$COMPOSE_FILE" "$LOCAL_COMPOSE_TMP" - mv -f "$LOCAL_ENV_TMP" "$REMOTE_DIR/.env" - mv -f "$LOCAL_COMPOSE_TMP" "$REMOTE_DIR/docker-compose.yml" - - for service in $SERVICES; do - docker image inspect "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" >/dev/null - done - - cd "$REMOTE_DIR" - export DEPLOY_ENV=development - export IMAGE_REPOSITORY IMAGE_TAG - export PUBLISHED_BIND_ADDRESS=127.0.0.1 - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - pull $SERVICES - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - up -d --remove-orphans --pull never $SERVICES - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - ps $SERVICES - exit 0 -fi - -run_ssh() { - ssh -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$@" -} - -run_ssh install -d -m 750 "$REMOTE_DIR" - -SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" -REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" - -run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" - -run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE' -set -eu -remote_dir="$1" -env_tmp="$2" -compose_tmp="$3" -chmod 600 "$env_tmp" -chmod 644 "$compose_tmp" -mv -f "$env_tmp" "$remote_dir/.env" -mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" -REMOTE_PREPARE - -for service in $SERVICES; do - image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG" - docker image inspect "$image" >/dev/null - echo "传输开发镜像: $image" - docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' -done - -run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -image_repository="$2" -image_tag="$3" -service_csv="$4" -project_name="$5" -services=$(printf '%s' "$service_csv" | tr ',' ' ') - -cd "$remote_dir" -export DEPLOY_ENV=development -export IMAGE_REPOSITORY="$image_repository" -export IMAGE_TAG="$image_tag" -export PUBLISHED_BIND_ADDRESS=127.0.0.1 - -docker compose \ - --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ - up -d --remove-orphans --pull never $services - -docker compose \ - --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ - ps $services -REMOTE_DEPLOY diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 8c720a1fe..cbff74731 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -1,225 +1,7 @@ #!/usr/bin/env sh set -eu - -if [ "$#" -ne 5 ]; then - echo "用法: $0 <环境> <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 - exit 2 -fi - -DEPLOY_ENV="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -SERVICE_CSV="$4" -ENV_FILE="$5" - -if [ ! -f "$ENV_FILE" ]; then - echo "环境文件不存在: $ENV_FILE" >&2 - exit 2 -fi - -SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') - -for service in $SERVICES; do - case "$service" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $service" >&2 - exit 2 - ;; - esac +[ "$#" -eq 5 ] || { echo "Usage: $0 test repository tag service-csv env-file" >&2; exit 2; } +[ "$1" = "test" ] || exit 2 +for service in $(printf '%s' "$4" | tr ',' ' '); do + sh deploy/scripts/deploy-built-service.sh "$1" "$2" "$3" "$service" "$5" done - -env_file_has_key() { - grep -q "^$1=" "$ENV_FILE" -} - -env_file_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -DEPLOY_REGION=CN -for service in $SERVICES; do - case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; - event-bridge-service) - BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" - if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then - BRIDGE_REGION="$(env_file_value REGION_CODE)" - fi - if [ -z "$BRIDGE_REGION" ]; then - echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 - exit 2 - fi - case "$BRIDGE_REGION" in - CN) ;; - OVERSEAS) DEPLOY_REGION=OVERSEAS ;; - *) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;; - esac - ;; - esac -done -if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - for service in $SERVICES; do - case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; - *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; - esac - done - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas" -else - COMPOSE_FILE="deploy/docker-compose.yml" - PROJECT_NAME="firstleg-${DEPLOY_ENV}" -fi - -if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署海外服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -case " $SERVICES " in - *" gateway-overseas "*) - if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then - echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 - exit 2 - fi - ;; -esac - -case " $SERVICES " in - *" finance "*) - for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" firstleg "*) - if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then - echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 - exit 2 - fi - ;; -esac - -case " $SERVICES " in - *" lastleg-service "*) - for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" warehouse-overseas-service "*) - for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" event-bridge-service "*) - if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" - else - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" - fi - for key in $EVENT_BRIDGE_KEYS; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" file "*) - if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then - echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 - exit 2 - fi - if ! env_file_has_key MQ_TOPIC_PREFIX; then - echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX:测试填 test-,生产填空字符串" >&2 - exit 2 - fi - MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) - case "$DEPLOY_ENV" in - test) - if [ "$MQ_PREFIX" != "test-" ]; then - echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 - exit 2 - fi - ;; - production) - if [ -n "$MQ_PREFIX" ]; then - echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须显式为空字符串" >&2 - exit 2 - fi - ;; - *) - if [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; then - echo "预发布环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为非空且不能使用 test-" >&2 - exit 2 - fi - ;; - esac - ;; -esac - -export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG - -if [ "$DEPLOY_ENV" = "development" ]; then - DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \ - "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE" -fi - -if [ "$DEPLOY_ENV" = "test" ] || [ "$DEPLOY_ENV" = "production" ]; then - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - pull $SERVICES - for service in $SERVICES; do - if [ "$DEPLOY_ENV" = test ]; then - docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \ - up -d --no-deps --pull never "$service" - continue - fi - sh deploy/scripts/deploy-blue-green-service.sh \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$service" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME" - done - exit 0 -fi - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - pull $SERVICES - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - up -d --remove-orphans --pull never $SERVICES - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - ps $SERVICES diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh index 9e97a52b6..f4960bd38 100644 --- a/deploy/scripts/initialize-test-databases.sh +++ b/deploy/scripts/initialize-test-databases.sh @@ -1,47 +1,15 @@ #!/usr/bin/env sh set -eu - -# 仅用于测试基础设施容器。所有 SQL 均使用 CREATE ... IF NOT EXISTS 或幂等扩展语句, -# 不删除已有业务数据;仓库样例数据使用固定 ID 重复写入。 -MYSQL_CONTAINER="${TEST_MYSQL_CONTAINER:-firstleg-test-mysql}" - -if ! docker inspect --type container "$MYSQL_CONTAINER" >/dev/null 2>&1; then - echo "测试 MySQL 容器不存在:$MYSQL_CONTAINER" >&2 - echo "请先执行 docker compose -f docker-compose-infra.yml up -d" >&2 - exit 1 -fi - -if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ]; then - echo "测试 MySQL 容器未运行:$MYSQL_CONTAINER" >&2 - exit 1 -fi - -for sql_file in \ - auth/src/main/resources/db/auth.sql \ - iam/src/main/resources/db/iam.sql \ - auth/src/main/resources/db/phase1-admin-accounts.sql \ - iam/src/main/resources/db/phase1-admin-rbac.sql \ - iam/src/main/resources/db/phase1-onboarding.sql \ - iam/src/main/resources/db/phase1-customer-profile.sql \ - iam/src/main/resources/db/phase1-member-permissions.sql \ - iam/src/main/resources/db/rbac-role-management-v2.sql \ - iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \ - order/src/main/resources/db/order.sql \ - order/src/main/resources/db/phase1-order-extension.sql \ - order/src/main/resources/db/phase1-masterdata-extension.sql \ - warehouse/src/main/resources/db/warehouse.sql \ - warehouse/src/main/resources/db/warehouse-partner.sql \ - warehouse/src/main/resources/db/warehouse-express-company.sql \ - warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql \ - warehouse/src/main/resources/db/warehouse-seed.sql \ - file/src/main/resources/db/file.sql -do - if [ ! -f "$sql_file" ]; then - echo "找不到初始化 SQL:$sql_file" >&2 - exit 1 - fi - echo "初始化测试数据库:$sql_file" - docker exec -i "$MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" +# Empty-schema baseline only. Never invoked by Compose/Jenkins automatically. +[ "${ALLOW_TEST_SCHEMA_INITIALIZATION:-false}" = "true" ] || { echo 'Explicit TEST SQL authorization required' >&2; exit 2; } +: "${TEST_MYSQL_CONTAINER:?Specify the authorized TEST MySQL container}" +[ "$#" -eq 2 ] || { echo 'Usage: initialize-test-databases.sh ' >&2; exit 2; } +for sql_file in "$1" "$2"; do + [ -f "$sql_file" ] || { echo "Missing reviewed baseline: $sql_file" >&2; exit 2; } +done +[ "$1" != "$2" ] || { echo 'Two distinct reviewed baselines required' >&2; exit 2; } +[ "$(docker inspect -f '{{.State.Running}}' "$TEST_MYSQL_CONTAINER")" = 'true' ] || { echo 'TEST MySQL is not running' >&2; exit 1; } +for sql_file in "$1" "$2"; do + echo "Applying reviewed TEST baseline: $sql_file" + docker exec -i "$TEST_MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" done - -echo "测试数据库初始化完成。" diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e22eec804..7fefc9ca5 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -3,11 +3,6 @@ services: artifactId: gateway jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar port: 9000 - gateway-overseas: - artifactId: gateway-overseas - jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar - port: 9300 - regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar @@ -44,17 +39,10 @@ services: artifactId: lastleg-service jar: lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar port: 9303 - regions: [OVERSEAS] warehouse-overseas-service: artifactId: warehouse-overseas-service jar: warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar port: 9305 - regions: [OVERSEAS] - event-bridge-service: - artifactId: event-bridge-service - jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar - port: 9312 - regions: [CN, OVERSEAS] audit-service: artifactId: audit-service jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 1045b098b..0729767aa 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,4 +1,4 @@ -# 区域数据库空库初始化 +# 单测试环境数据库空库初始化 ## 唯一入口与区域边界 @@ -6,11 +6,11 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 223 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 135 | -- 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 -- 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 +- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 +- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -24,7 +24,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/135`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -44,9 +44,9 @@ | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | -| Lastleg | 国内固定管理员对应的 USER/AUTHORIZATION version=0 安全投影,当前角色及完整 106 项海外权限 | 客户、订单、库存、发运门禁事实或新账号 | +| Lastleg | 无身份或授权种子;复用统一Auth/IAM会话 | 客户、订单、库存、发运门禁事实或独立账号 | -海外管理员投影只是国内已定义身份的初始化快照,不是独立账号或权限中心。后续变更仍由 IAM 事件同步,不提供历史投影回填。 +IAM保留subject/tenant持久版本表;安全事件专用Outbox及Lastleg授权投影表已退出新空库基线。旧库不在本次脚本中执行删除或历史回填。 扣货写权限只默认授予平台管理员,与 IAM 扣货权限合同一致;旧脚本重放时通过范围授权额外授予订单运营的那一条关联不进入新基线。 diff --git a/deploy/sql/migration-scopes.json b/deploy/sql/migration-scopes.json index ebe28e67e..aecc3c5a6 100644 --- a/deploy/sql/migration-scopes.json +++ b/deploy/sql/migration-scopes.json @@ -2,7 +2,6 @@ "schemaVersion": 1, "services": [ "gateway", - "gateway-overseas", "auth", "iam", "order", @@ -10,7 +9,6 @@ "firstleg", "file", "audit-service", - "event-bridge-service", "finance", "integration-service", "print", @@ -19,7 +17,7 @@ ], "regions": { "CN": { - "baseline": "baseline/cn/cn-baseline.sql", + "baseline": "头程数据库统一增量-MySQL8.sql", "schemas": [ { "name": "baiyue_auth", "services": ["auth"], "order": 10 }, { "name": "baiyue_iam", "services": ["iam"], "order": 20 }, @@ -28,16 +26,14 @@ { "name": "baiyue_firstleg", "services": ["firstleg"], "order": 50 }, { "name": "baiyue_file", "services": ["file"], "order": 60 }, { "name": "baiyue_audit", "services": ["audit-service"], "order": 70 }, - { "name": "baiyue_event_bridge", "services": ["event-bridge-service"], "order": 80 }, { "name": "baiyue_finance", "services": ["finance"], "order": 90 } ] }, "OVERSEAS": { - "baseline": "baseline/overseas/overseas-baseline.sql", + "baseline": "海外区域统一增量-MySQL8.sql", "schemas": [ { "name": "baiyue_lastleg", "services": ["lastleg-service"], "order": 10 }, - { "name": "baiyue_lastlag_warehouse", "services": ["warehouse-overseas-service"], "order": 20 }, - { "name": "baiyue_event_bridge_overseas", "services": ["event-bridge-service"], "order": 30 } + { "name": "baiyue_lastlag_warehouse", "services": ["warehouse-overseas-service"], "order": 20 } ] } } diff --git a/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql b/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql new file mode 100644 index 000000000..25c2b32ae --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: CN +-- Schema: baiyue_finance +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql b/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql new file mode 100644 index 000000000..37bcf1e53 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: CN +-- Schema: baiyue_finance +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql new file mode 100644 index 000000000..65a2dc56d --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql new file mode 100644 index 000000000..c0d3cd9c8 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql new file mode 100644 index 000000000..dba0a5a22 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql new file mode 100644 index 000000000..deb635d30 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c7ffeb09f..5a75105ef 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1,5 +1,5 @@ -- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 --- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 @@ -319,25 +319,6 @@ CREATE TABLE iam_role_permission ( KEY idx_rp_tenant (tenant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色权限关联表:定义角色可访问的菜单、页面、按钮和API资源'; -CREATE TABLE iam_security_event_outbox ( - event_id varchar(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', - event_type varchar(64) NOT NULL COMMENT '安全事件类型', - aggregate_type varchar(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', - aggregate_id varchar(64) NOT NULL COMMENT '聚合标识', - payload_json json NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', - status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', - attempt_count int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', - next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', - lease_owner varchar(128) DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约到期时间', - last_error varchar(1000) DEFAULT NULL COMMENT '最近一次错误', - sent_at datetime(3) DEFAULT NULL COMMENT '发送成功时间', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (event_id), - KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), - KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IAM可靠安全事件本地发送箱'; CREATE TABLE iam_security_subject_version ( auth_user_id bigint NOT NULL COMMENT '认证账号ID', @@ -4661,88 +4642,6 @@ CREATE TABLE sys_audit_log ( KEY idx_audit_request (request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; --- 服务 schema:baiyue_event_bridge -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge; - -CREATE TABLE bridge_delivery_attempt ( - id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - attempt_no int NOT NULL COMMENT '投递序号', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', - response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', - error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', - started_at datetime(3) NOT NULL COMMENT '开始时间', - finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), - KEY idx_bridge_attempt_event (event_id,started_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件投递尝试记录'; - -CREATE TABLE bridge_inbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', - retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件已接收箱'; - -CREATE TABLE bridge_outbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件待投递箱'; - -- 服务 schema:baiyue_finance CREATE DATABASE IF NOT EXISTS baiyue_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_finance; @@ -8142,4 +8041,78 @@ CREATE TABLE fl_sailing_booking_link ( CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') ) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; +USE baiyue_firstleg; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_finance; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_firstleg; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +USE baiyue_finance; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 176fe0178..8f54c1129 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,93 +1,11 @@ -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 --- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci; SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; --- 服务 schema:baiyue_event_bridge_overseas -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge_overseas; - -CREATE TABLE bridge_delivery_attempt ( - id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - attempt_no int NOT NULL COMMENT '投递序号', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', - response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', - error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', - started_at datetime(3) NOT NULL COMMENT '开始时间', - finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), - KEY idx_bridge_attempt_event (event_id,started_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件投递尝试记录'; - -CREATE TABLE bridge_inbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', - retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件已接收箱'; - -CREATE TABLE bridge_outbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件待投递箱'; - -- 服务 schema:baiyue_lastleg CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_lastleg; @@ -2523,36 +2441,7 @@ CREATE TABLE ll_return_receipt_item ( CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细'; -CREATE TABLE ll_security_event_inbox ( - event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨区安全事件ID', - event_type varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'IAM安全事件类型', - payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256哈希', - payload_json json NOT NULL COMMENT '冻结事件载荷JSON', - status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', - result varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', - received_at datetime(3) NOT NULL COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全事件消费者收件箱'; -CREATE TABLE ll_security_projection ( - aggregate_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', - aggregate_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户或客户聚合ID', - tenant_id bigint NOT NULL COMMENT '平台0或客户ID', - security_status varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前安全状态:ACTIVE或DISABLED', - permission_version bigint DEFAULT NULL COMMENT '当前单调权限版本', - roles_json json NOT NULL COMMENT 'IAM投影的当前角色快照', - authorities_json json NOT NULL COMMENT 'IAM投影的当前海外权限快照', - aggregate_version bigint NOT NULL COMMENT '事件聚合单调版本', - payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '当前版本载荷哈希', - last_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后应用的事件ID', - changed_at datetime(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at datetime(3) NOT NULL COMMENT '投影更新时间', - PRIMARY KEY (aggregate_type,aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id,permission_version) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全状态与权限版本投影'; CREATE TABLE ll_us_truck_order ( id bigint NOT NULL COMMENT '主键ID', @@ -3064,9 +2953,43 @@ CREATE TABLE wh_resource_lock ( USE baiyue_lastleg; --- 系统基线 ll_security_projection:2 行。 -INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); -INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); -- assert_relabel_transfer_latest_schema_ready: 贴标申请与仓储转运最新表结构已完整部署。 -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 138 AS initialized_tables; +USE baiyue_lastleg; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_lastleg; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 135 AS initialized_tables; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 522ab5e45..88c858837 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 20 个 Maven 模块,其中 7 个是公共/依赖模块,13 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -24,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 +当前源码与发布清单统一为 TEST 单机、一个 Gateway/Auth 会话和 Nacos 命名空间;业务服务保留独立 Schema,可靠事件使用 Outbox 直接投递及消费幂等。真实环境切换仍待验收,不能把本地配置视为已部署。 ## 2. 根目录结构 @@ -39,7 +39,7 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 -├─ gateway-overseas/ # 海外区域 API 网关 +├─ gateway-overseas/ # 退役待清理,不参与构建/发布 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 @@ -52,7 +52,7 @@ baiyue-firstleg/ ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 ├─ integration-service/ # 第三方平台集成适配 -├─ event-bridge-service/ # 国内外跨区域事件接收桥 +├─ event-bridge-service/ # 退役待清理,不参与构建/发布 │ ├─ config/ # Nacos 配置模板 ├─ deploy/ # Jenkins、Docker 使用的部署文件 @@ -90,7 +90,6 @@ baiyue-firstleg/ | 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 | | --- | --- | ---: | --- | --- | | `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 | -| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 | | `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 | | `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 | | `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 | @@ -99,9 +98,8 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、出库、派送和本土业务;复用统一会话鉴权 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | -| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。 @@ -158,9 +156,9 @@ Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权 Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三方协议适配及能力状态。入库测量与计费重量 OCR 由 File 调用,业务图片和入库状态不归 Integration;服务不使用业务数据库,真实外部调用默认关闭,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。 -### 5.11 `event-bridge-service/`:跨区域事件桥 +### 5.11 业务事件直接投递 -Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 +Firstleg、Lastleg 与 Finance 通过内部服务身份直接投递业务事件,保留事务 Outbox、消费幂等、结构化 ACK 与失败重试;精确事件表只维护在外层 `docs/统一认证与服务通信契约.md`。旧 Event Bridge 和海外 Gateway 源码退役待清理,不作为当前实现入口。 头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。 @@ -170,11 +168,10 @@ Event Bridge 负责接收国内外服务器之间的必要业务事件,并通 ```text config/nacos/ -├─ local/ # 本地联调配置 -└─ cn/ # 国内服务器配置模板 +└─ test/ # 公共配置 + 13 服务,统一实际 Namespace ID ``` -`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`test/` 保存公共配置和全部保留服务的 14 个 Data ID;旧区域目录已退役。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -183,17 +180,16 @@ deploy/ ├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ env/.env.test.example # 环境变量示例 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 -├─ docker-compose.yml # 国内区域服务编排 -├─ docker-compose.overseas.yml # 海外区域服务编排 +├─ docker-compose.yml # 13 服务统一 TEST 编排 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 -├─ sql/ # 测试/存量环境统一迁移入口与说明 +├─ sql/ # TEST 空库基线入口与执行说明 └─ service-map.yml # 模块、JAR、端口映射 ``` ### `scripts/` - `local-env.ps1`:本地服务环境变量 -- `publish-local-nacos-config.ps1`:发布本地 Nacos 配置 +- `publish-nacos-config.ps1`:发布统一 TEST Nacos 配置;DryRun 不联网,正式发布须授权 - `phase1-acceptance.ps1`:订单与客户验收入口 - `phase1-business-maturity-regression.ps1`:业务成熟度回归 - `phase1-onboarding-order-e2e.ps1`:企业入驻到订单端到端验证 @@ -281,6 +277,8 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的13 个保留服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 +- 国内头程与海外仓共用 TEST 单机基础设施与 Nacos 命名空间,保留各服务数据归属和 Schema。 + +前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。 diff --git a/file/src/main/resources/application-dev.yml b/file/src/main/resources/application-dev.yml deleted file mode 100644 index 334396130..000000000 --- a/file/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FILE_DB_USERNAME:root} - password: ${FILE_DB_PASSWORD:123456} diff --git a/file/src/main/resources/application-test.yml b/file/src/main/resources/application-test.yml deleted file mode 100644 index fec0b48d0..000000000 --- a/file/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${FILE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${FILE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index be91f53d6..4c5595c8c 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -2,24 +2,24 @@ server: port: ${FILE_PORT:9206} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: file-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} openfeign: client: config: @@ -49,8 +49,7 @@ rocketmq: send-message-timeout: 3000 baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} clients: iam: url: ${IAM_SERVICE_URL:} diff --git a/finance/AGENTS.md b/finance/AGENTS.md index b4b5d848d..cffd9fd56 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -9,6 +9,8 @@ ## 快速查询 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/finance/event-operations/{entryId}` 查询及 `/retry` 写入,只允许资金结果事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + | 目标 | 首选入口 | |---|---| | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | @@ -32,7 +34,7 @@ | 收款/成本凭证 | `client/FinanceFileClient.java`;文件归属与下载规则见 `../file/AGENTS.md` | | 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` | | 数据库 | `src/main/resources/db/finance.sql`、`src/main/resources/db/fee-source-event-migration.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | -| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` | +| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/test/finance-service.yml`、`../gateway/src/main/resources/application.yml` | | 独立服务部署链 | `../Jenkinsfile`、`../Jenkinsfile.service`、`../deploy/service-map.yml`、`../deploy/docker-compose.yml`、`../deploy/scripts/deploy-services.sh`;契约检查见 `../scripts/finance-deployment-contract.test.mjs`,部署授权边界见 `../../docs/测试环境Jenkins部署指南.md` | | 测试环境全链路验收模块 | `../scripts/admin-test-env-modules/finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs` 及同名 `*-contract.test.mjs`;仅表示验收入口,不代表环境已部署或验收通过 | | 测试 | `src/test/java/com/baiyue/finance/`;执行 `mvn -pl finance -am test` | diff --git a/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java b/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java new file mode 100644 index 000000000..d24be19e6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/finance/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("fin_wallet_event_outbox", "event_id", "status", Set.of(EventTypes.WALLET_COMMAND_COMPLETED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index dd3f867d6..9049e00da 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -6,7 +6,6 @@ import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -36,9 +35,8 @@ public class BusinessFeeSourceEventService { throw error("FIN-EVT-001", "跨区费用事件信封无效"); } if (!EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(envelope.eventType()) - || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.OVERSEAS - || envelope.targetRegion() != RegionCode.CN) { - throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效"); + || envelope.eventVersion() != 1) { + throw error("FIN-EVT-001", "跨区费用事件类型或版本无效"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index 5677355b4..e42137930 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.common.event.WalletCommandRequestedV1; import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; @@ -108,9 +107,8 @@ public class WalletCommandEventService { try { EventEnvelopeValidator.requireValid(envelope); } catch (RuntimeException exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令信封无效"); } if (!EventTypes.WALLET_COMMAND_REQUESTED.equals(envelope.eventType()) - || envelope.eventVersion() != WalletCommandRequestedV1.VERSION - || envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) { - throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效"); + || envelope.eventVersion() != WalletCommandRequestedV1.VERSION) { + throw new FinanceEventException("FIN-EVT-001", "资金指令类型或版本无效"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); @@ -146,7 +144,7 @@ public class WalletCommandEventService { JsonNode payload = objectMapper.valueToTree(result); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); EventEnvelope completed = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_COMPLETED, - WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, + WalletCommandCompletedV1.VERSION, Instant.now(), String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null, commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid(); jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java index ba02103bc..d338d3de2 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -1,6 +1,12 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; import com.baiyue.common.core.retry.BoundedRetryPolicy; @@ -21,8 +27,10 @@ import java.util.UUID; @Component @RequiredArgsConstructor @Slf4j -public class WalletResultOutboxRelay { +public class WalletResultOutboxRelay implements EventDeliveryProducer { private final JdbcTemplate jdbc; + private final EventDeliveryJournal journal; + private final EventDeliveryMetrics metrics; private final ObjectMapper objectMapper; private final WalletResultLastlegClient client; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -42,16 +50,21 @@ public class WalletResultOutboxRelay { var policy = retryPolicy(); String worker = UUID.randomUUID().toString(); LocalDateTime now = LocalDateTime.now(); - int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", - worker, now, now, eventId, now); - if (claimed != 1) return; - Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId); + if (!journal.begin(worker, Stream.FINANCE.service, "WalletCommandCompleted", eventId, eventId, + Stream.FINANCE.target("WalletCommandCompleted"), now, + () -> jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", + worker, now, now, eventId, now) == 1)) return; + Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker); int failures = ((Number) row.get("retry_count")).intValue(); if (policy.exhausted(failures)) { - jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", - "自动投递次数已耗尽(含租约超时)", eventId, worker); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", + "自动投递次数已耗尽(含租约超时)", eventId, worker)); return; } + Outcome outcome; + String diagnostic = null; + String auditError = null; boolean requestStarted = false; try { EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); @@ -59,21 +72,31 @@ public class WalletResultOutboxRelay { var response = client.receive(internalToken, envelope); EventDeliveryFailure.requireSuccessful(response); response.data().requireMatches(envelope); - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", - eventId, worker); + outcome = Outcome.valueOf(response.data().outcome().name()); } catch (Exception exception) { - String error = EventDeliveryFailure.diagnostic(exception, internalToken); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); boolean permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - int retries = failures + 1; - String status = permanent || policy.exhausted(retries) ? "DEAD" : "PENDING"; - LocalDateTime next = permanent || policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries)); - jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", - status, retries, next, safe(error), eventId, worker); - log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, error); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.RETRY || result == Outcome.DEAD) { + return jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?", + result == Outcome.DEAD ? "DEAD" : "PENDING", failures + 1, next == null ? finished : next, safe(error), finished, eventId, worker); + } + return jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?", + finished, finished, eventId, worker); + }); } + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FINANCE, LocalDateTime.now()); } + private List candidates() { return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); } @@ -84,10 +107,12 @@ public class WalletResultOutboxRelay { for (Map row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=? AND locked_at service.consume(tampered)).isInstanceOf(FinanceEventException.class) @@ -122,7 +121,7 @@ class WalletCommandEventServiceTest { void rejectsCustomerMismatchBeforeTouchingWallet() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00")); envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), - envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), "32", envelope.aggregateType(), + envelope.occurredAt(), "32", envelope.aggregateType(), envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); @@ -139,8 +138,7 @@ class WalletCommandEventServiceTest { "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z")); JsonNode payload = mapper.valueToTree(command); return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED, - WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), - RegionCode.OVERSEAS, RegionCode.CN, String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, + WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java index bd88b0dbd..9d27000cc 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java @@ -193,7 +193,7 @@ class WalletCommandTransactionTest { new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z")); var payload = mapper.valueToTree(command); return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, - Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "31", "WALLET_HOLD", "PO-1", 1, + Instant.now(), "31", "WALLET_HOLD", "PO-1", 1, null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java index acb6c036e..f26132b1f 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java @@ -8,9 +8,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.web.EventDeliveryMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; +import java.time.LocalDateTime; import java.util.UUID; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; @@ -23,15 +28,16 @@ class WalletResultOutboxLeaseTest { var jdbc = new JdbcTemplate(new DriverManagerDataSource( "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, last_error VARCHAR(1000))"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); var event = new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_COMPLETED, 1, - Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + Instant.now(), "1", "WALLET_HOLD", "ORDER-1", 1L, null, "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',DATEADD('SECOND',-5,CURRENT_TIMESTAMP))", event.eventId(), mapper.writeValueAsString(event)); var client = mock(WalletResultLastlegClient.class); - var relay = new WalletResultOutboxRelay(jdbc, mapper, client); + var relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client); ReflectionTestUtils.setField(relay, "internalToken", "token"); ReflectionTestUtils.setField(relay, "maxAttempts", 12); var firstEntered = new CountDownLatch(1); @@ -54,8 +60,12 @@ class WalletResultOutboxLeaseTest { Future first = pool.submit(() -> relay.deliver(event.eventId())); assertThat(firstEntered.await(10, TimeUnit.SECONDS)).isTrue(); String oldOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); - // Simulate lease recovery while the original HTTP request has not returned. - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL"); + // The actual recovery path must finish the old audit and defer the new attempt. + jdbc.update("UPDATE fin_wallet_event_outbox SET locked_at=?", LocalDateTime.now().minusSeconds(61)); + ReflectionTestUtils.invokeMethod(relay, "recoverExpired"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED"); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now()); + jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1)); Future second = pool.submit(() -> relay.deliver(event.eventId())); assertThat(secondEntered.await(10, TimeUnit.SECONDS)).isTrue(); String newOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); @@ -64,10 +74,12 @@ class WalletResultOutboxLeaseTest { first.get(10, TimeUnit.SECONDS); assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("SENDING"); assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isEqualTo(newOwner); - assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); releaseSecond.countDown(); second.get(10, TimeUnit.SECONDS); assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, newOwner)).isEqualTo("APPLIED"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED"); } finally { releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow(); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java index dfa0b1046..299e4ed63 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java @@ -8,12 +8,16 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.web.EventDeliveryMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -28,17 +32,18 @@ class WalletResultRetryTest { jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_error VARCHAR(1000))"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); eventId = UUID.randomUUID().toString(); var event = new EventEnvelope(eventId, EventTypes.WALLET_COMMAND_COMPLETED, 1, Instant.now(), - RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "1", "WALLET_HOLD", "ORDER-1", 1L, null, "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); originalJson = mapper.writeValueAsString(event); jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',?)", eventId, originalJson, LocalDateTime.now().minusSeconds(1)); client = mock(WalletResultLastlegClient.class); - relay = new WalletResultOutboxRelay(jdbc, mapper, client); + relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client); ReflectionTestUtils.setField(relay, "internalToken", "token"); ReflectionTestUtils.setField(relay, "maxAttempts", 3); ReflectionTestUtils.setField(relay, "retryJitter", 0.0); @@ -66,6 +71,7 @@ class WalletResultRetryTest { @Test void expiredLastAttemptGoesDeadWithoutAnotherHttpRequest() { jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',retry_count=2,locked_by='expired',locked_at=?", LocalDateTime.now().minusSeconds(61)); + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61)); relay.relay(); assertThat(status()).isEqualTo("DEAD"); assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); @@ -75,6 +81,7 @@ class WalletResultRetryTest { @Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() { jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?", LocalDateTime.now().minusSeconds(61)); + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61)); relay.relay(); assertThat(status()).isEqualTo("PENDING"); assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); @@ -82,5 +89,27 @@ class WalletResultRetryTest { verifyNoInteractions(client); } + @Test void auditStartFailurePreventsHttpAndRollsBackClaim() { + jdbc.execute("DROP TABLE event_delivery_attempt"); + assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("PENDING"); + verifyNoInteractions(client); + } + + @Test void successfulRemoteResponseWithFailedAuditCommitLeavesStartedLeaseForRecovery() throws Exception { + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var event = mapper.readValue(originalJson, EventEnvelope.class); + when(client.receive(anyString(), any())).thenAnswer(invocation -> { + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + return com.baiyue.common.core.api.ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + }); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("SENDING"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + verify(client).receive(anyString(), any()); + } + private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); } } diff --git a/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java new file mode 100644 index 000000000..ef3fcfb82 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventOperationsControllerTest { + @Test void credentialFailureCannotReadOrMutateDatabase() { + var jdbc = mock(JdbcTemplate.class); + var transactions = mock(PlatformTransactionManager.class); + for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) { + var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured); + assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class); + } + verifyNoInteractions(jdbc, transactions); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 2239efda4..e9bea312c 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,6 +9,8 @@ ## 模块入口 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + 柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 | 目标 | 入口 | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java new file mode 100644 index 000000000..1535c3e26 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/firstleg/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("fl_inbound_outbox", "id", "publish_status", Set.of(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java index 25686ee0c..7a991beb9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -10,7 +10,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; @@ -68,7 +67,7 @@ public class CarrierScheduleNodeOutboxService { long aggregateVersion = Math.max(0, version == null ? 0 : version); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, - fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, + fact.getActualAt().atZone(zone).toInstant(), "0", "CONTAINER_BOOKING", string(containerBookingId), aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, payloadNode, payloadHash).requireValid(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index e5ac78d78..bf8fb343b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -29,35 +29,42 @@ public class LoadingCompletedOutboxRepository { .orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId) .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() - .eq(InboundOutboxEntity::getId, id) - .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) - .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") - .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) - .or().le(InboundOutboxEntity::getNextRetryAt, now)) - .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") - .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) - .setSql("retry_count = retry_count + CASE WHEN publish_status='PROCESSING' THEN 1 ELSE 0 END") - .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) - .set(InboundOutboxEntity::getLockedAt, now)) == 1; + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) + .eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now)) + .set(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .set(InboundOutboxEntity::getLockedBy, worker).set(InboundOutboxEntity::getLockedAt, now)) == 1; + } + + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) + .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker) + .le(InboundOutboxEntity::getLockedAt, cutoff) + .set(InboundOutboxEntity::getPublishStatus, next == null ? "DEAD" : "PENDING") + .set(InboundOutboxEntity::getRetryCount, failures).set(InboundOutboxEntity::getNextRetryAt, next) + .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) + .set(InboundOutboxEntity::getLastError, truncate(error))); } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker) + public int published(long id, String worker, LocalDateTime now) { return mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) + .set(InboundOutboxEntity::getNextRetryAt, null) .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) .set(InboundOutboxEntity::getLastError, null)); } - public void dead(long id, String worker, int failures, String error) { - mapper.update(null, claimedBase(id, worker) + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "DEAD") .set(InboundOutboxEntity::getRetryCount, failures) .set(InboundOutboxEntity::getNextRetryAt, null) .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) .set(InboundOutboxEntity::getLastError, truncate(error))); } - public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker) + public int retry(long id, String worker, LocalDateTime retryAt, String error) { return mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING") .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) .set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index 30a5964d3..956272df5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -1,6 +1,12 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; @@ -21,10 +27,12 @@ import java.util.UUID; /** 至少一次投递国内头程业务事件;Lastleg按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j -public class LoadingCompletedOutboxRelay { +public class LoadingCompletedOutboxRelay implements EventDeliveryProducer { @Resource private LoadingCompletedOutboxRepository repository; @Resource private LastlegEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @@ -37,45 +45,63 @@ public class LoadingCompletedOutboxRelay { public void relay() { if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); - LocalDateTime now=LocalDateTime.now(); - LocalDateTime expiredLeaseBefore=now.minusSeconds(leaseSeconds); - for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) { - String workerId = UUID.randomUUID().toString(); - now = LocalDateTime.now(); - expiredLeaseBefore = now.minusSeconds(leaseSeconds); - - if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue; - InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId); - if(row==null) continue; + LocalDateTime scan = LocalDateTime.now(); + for (InboundOutboxEntity candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(); + if ("PROCESSING".equals(candidate.getPublishStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. + } + String worker = UUID.randomUUID().toString(); + String type = Stream.FIRSTLEG.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.FIRSTLEG.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FIRSTLEG.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; + InboundOutboxEntity row = repository.claimed(candidate.getId(), worker); + if (row == null) continue; int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); if (policy.exhausted(failures)) { - repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); continue; } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; boolean requestStarted = false; try { - EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); - EventEnvelopeValidator.requireValid(event); + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); requestStarted = true; - var response=client.receive(internalToken,event); - requireAcknowledgement(event, response); - repository.published(row.getId(),workerId,LocalDateTime.now()); - } catch(Exception exception) { - boolean permanent = EventDeliveryFailure.isPermanent(exception) + var response = client.receive(internalToken, event); + EventDeliveryFailure.requireSuccessful(response); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); + } catch (Exception exception) { + permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - String error = EventDeliveryFailure.diagnostic(exception, internalToken); - if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); - else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), error); - log.warn("头程业务事件投递失败: eventType={}, eventKey={}, reason={}", - row.getEventType(), row.getEventKey(), error); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, next, error); + return repository.published(row.getId(), worker, finished); + }); } } - private void requireAcknowledgement(EventEnvelope event, - ApiResponse response) { - EventDeliveryFailure.requireSuccessful(response); - response.data().requireMatches(event); - } - + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FIRSTLEG, LocalDateTime.now()); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 4832d048a..93b11a592 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -11,7 +11,6 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV3; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; @@ -124,7 +123,7 @@ public class LoadingCompletedOutboxService { String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; String traceId = EventIdentifiers.newUuid(); return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 3, - Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN", + Instant.now(), String.valueOf(accountId), "LOADING_PLAN", String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, traceId, eventKey, null, payloadNode, hash).requireValid(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index e57b3fae5..de19bb079 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -8,7 +8,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.mapper.ContainerBookingMapper; @@ -83,9 +82,8 @@ public class OverseasContainerEventConsumer { if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) { throw new EventProtocolException("不支持的海外货柜事件"); } - if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN - || !"CONTAINER_BOOKING".equals(event.aggregateType())) { - throw new EventProtocolException("海外货柜事件区域或聚合类型无效"); + if (!"CONTAINER_BOOKING".equals(event.aggregateType())) { + throw new EventProtocolException("海外货柜事件聚合类型无效"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { throw new EventProtocolException("payloadHash 与事件负载不一致"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java index 3693a8715..cafd14e10 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -9,7 +9,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasDeliveryCompletedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.client.OrderFulfillmentCompletionClient; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; @@ -102,7 +101,6 @@ public class OverseasDeliveryCompletedConsumer { private void validate(EventEnvelope event) { event.requireValid(); if (!EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) || event.eventVersion() != 1 - || event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) { throw new EventProtocolException("不支持的海外派送完成事件"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java index 261a74004..8b98100b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java @@ -9,7 +9,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.entity.InboundHoldRecordEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; @@ -87,8 +86,7 @@ public class OverseasInboundOrderDetainedConsumer { private void validateEnvelope(EventEnvelope event) { event.requireValid(); if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) - || event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS - || event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) { + || event.eventVersion() != 1 || !"INBOUND_ORDER".equals(event.aggregateType())) { throw new EventProtocolException("不支持的海外入仓单暂扣事件"); } if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { diff --git a/firstleg/src/main/resources/application-dev.yml b/firstleg/src/main/resources/application-dev.yml deleted file mode 100644 index d0360bf48..000000000 --- a/firstleg/src/main/resources/application-dev.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FIRSTLEG_DB_USERNAME:root} - password: ${FIRSTLEG_DB_PASSWORD:123456} - -# 仅本地开发环境输出 MyBatis 执行 SQL、参数与结果摘要到 IDEA 控制台。 -mybatis-plus: - configuration: - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl diff --git a/firstleg/src/main/resources/application-test.yml b/firstleg/src/main/resources/application-test.yml deleted file mode 100644 index f78a7a9b3..000000000 --- a/firstleg/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FIRSTLEG_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${FIRSTLEG_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${FIRSTLEG_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 6033ef4fa..c2e6d256c 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -2,24 +2,24 @@ server: port: ${FIRSTLEG_PORT:9202} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: firstleg-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} servlet: multipart: max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB} @@ -56,10 +56,8 @@ baiyue: max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 56fb62383..20b5196f8 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -1079,3 +1079,38 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku ( KEY idx_dropship_carton_sku_code (sku_code,carton_id), CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id) ) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实'; + +CREATE TABLE IF NOT EXISTS event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +CREATE TABLE IF NOT EXISTS event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java new file mode 100644 index 000000000..5ba00e738 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/EventOperationsControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventOperationsControllerTest { + @Test void credentialFailureCannotReadOrMutateDatabase() { + var jdbc = mock(JdbcTemplate.class); + var transactions = mock(PlatformTransactionManager.class); + for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) { + var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured); + assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class); + } + verifyNoInteractions(jdbc, transactions); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java index 4ee884bc3..5a90cb026 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java @@ -30,9 +30,9 @@ class LoadingCompletedOutboxRepositoryTest { when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); - assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + assertThat(repository.claim(1L, "worker", now)).isTrue(); assertThat(wrapper.getValue().getSqlSegment()) - .contains("next_retry_at", "<=", "publish_status", "locked_at"); + .contains("next_retry_at", "<=", "publish_status").doesNotContain("locked_at"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java new file mode 100644 index 000000000..63cf7e5df --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/OutboxLeaseDatabaseTest.java @@ -0,0 +1,57 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class OutboxLeaseDatabaseTest { + @Test void loadingCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE fl_inbound_outbox(id BIGINT PRIMARY KEY,tenant_id BIGINT,aggregate_type VARCHAR(64),aggregate_id BIGINT,event_version INT,aggregate_sequence INT,payload_hash VARCHAR(100),event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,publish_status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + var configuration = new MybatisConfiguration(); + configuration.addMapper(InboundOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundOutboxMapper.class); + var repository = new LoadingCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO fl_inbound_outbox(id,event_type,publish_status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "FirstLegLoadingPlanCompleted", now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("UPDATE fl_inbound_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); + assertThat(repository.claim(1L, "new", now)).isFalse(); + var next = now.plusSeconds(10); + assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next, + () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fl_inbound_outbox", Integer.class)).isEqualTo(1); + assertThat(repository.claim(1L, "early", now)).isFalse(); + assertThat(journal.begin("new", "firstleg", "FirstLegLoadingPlanCompleted", "1", null, "consumer", next, + () -> repository.claim(1L, "new", next))).isTrue(); + assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, + () -> repository.dead(1L, "old", 12, "late"))).isFalse(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PROCESSING"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isTrue(); + assertThat(jdbc.queryForObject("SELECT publish_status FROM fl_inbound_outbox", String.class)).isEqualTo("PUBLISHED"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java index 288886bb2..d11bacd22 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.client.LastlegEventClient; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; @@ -24,6 +23,11 @@ import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableExcept import java.time.Instant; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -35,11 +39,21 @@ class LoadingCompletedOutboxRelayTest { private ObjectMapper objectMapper; private LoadingCompletedOutboxRelay relay; + private EventDeliveryJournal journal; + @BeforeEach void setUp() { objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); relay = new LoadingCompletedOutboxRelay(); ReflectionTestUtils.setField(relay, "repository", repository); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + ReflectionTestUtils.setField(relay, "journal", journal); + lenient().when(repository.published(anyLong(), anyString(), any())).thenReturn(1); + lenient().when(repository.retry(anyLong(), anyString(), any(), anyString())).thenReturn(1); + lenient().when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1); ReflectionTestUtils.setField(relay, "client", client); ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); @@ -52,7 +66,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( com.baiyue.common.event.DeliveryAck.of(event, com.baiyue.common.event.DeliveryAck.Outcome.APPLIED))); @@ -68,7 +82,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenThrow(new IllegalStateException("failed internal-token\nremote")); @@ -84,7 +98,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow( new NoFallbackAvailableException("No fallback available.", @@ -102,7 +116,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure( ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456")); @@ -110,7 +124,7 @@ class LoadingCompletedOutboxRelayTest { relay.relay(); verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq( - "IllegalStateException: Lastleg拒绝头程业务事件: code=S0503, message=bridge inbox unavailable, " + "EventDeliveryFailure: Consumer rejected event: code=S0503, message=bridge inbox unavailable, " + "requestId=request-123, traceId=trace-456")); verify(repository, never()).published(anyLong(), anyString(), any()); } @@ -120,7 +134,7 @@ class LoadingCompletedOutboxRelayTest { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( new com.baiyue.common.event.DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), @@ -135,7 +149,7 @@ class LoadingCompletedOutboxRelayTest { InboundOutboxEntity row = row(event()); row.setRetryCount(11); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); relay.relay(); @@ -149,7 +163,7 @@ class LoadingCompletedOutboxRelayTest { InboundOutboxEntity row = row(event()); row.setRetryCount(12); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); relay.relay(); verifyNoInteractions(client); @@ -157,10 +171,24 @@ class LoadingCompletedOutboxRelayTest { verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); } + @Test + void expiredLeaseIsAuditedAndBackedOffWithoutImmediateHttp() throws Exception { + var row = row(event()); + row.setPublishStatus("PROCESSING"); row.setLockedBy("old"); row.setRetryCount(1); + journal.begin("old", "firstleg", row.getEventType(), "9001", null, "consumer", LocalDateTime.now().minusMinutes(2), () -> true); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.expire(eq(9001L), eq("old"), any(), eq(2), any(), anyString())).thenReturn(1); + relay.relay(); + verify(repository).expire(eq(9001L), eq("old"), any(), eq(2), argThat(next -> next.isAfter(LocalDateTime.now())), anyString()); + verify(repository, never()).claim(anyLong(), anyString(), any()); + verifyNoInteractions(client); + } + private InboundOutboxEntity row(EventEnvelope event) throws Exception { InboundOutboxEntity row = new InboundOutboxEntity(); row.setId(9001L); row.setEventKey(event.eventId()); + row.setEventType(event.eventType()); row.setPayloadJson(objectMapper.writeValueAsString(event)); return row; } @@ -170,7 +198,7 @@ class LoadingCompletedOutboxRelayTest { "LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null, "WH-1", "Warehouse", List.of())); return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, - Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1", + Instant.parse("2026-08-20T03:00:01Z"), "1", "LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index dbb9a5567..6878b6ee3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; import com.baiyue.firstleg.mapper.ContainerBookingMapper; @@ -83,7 +82,7 @@ class OverseasContainerEventConsumerTest { new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), - valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), + valid.occurredAt(), valid.tenantId(), valid.aggregateType(), valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(), valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64)); @@ -107,7 +106,7 @@ class OverseasContainerEventConsumerTest { var node = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, - Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1", + Instant.parse("2026-08-20T02:31:00Z"), "1", "CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null, "trace-12345678", "correlation-12345678", null, node, hash); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java index 8fa0a26ba..215339e11 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumerTest.java @@ -78,7 +78,7 @@ class OverseasDeliveryCompletedConsumerTest { "101", "IN-1", "201", "FL-201", "201", "ONT8", time); var node = json.valueToTree(payload); return new EventEnvelope("00000000-0000-4000-8000-000000000001", - EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, RegionCode.OVERSEAS, RegionCode.CN, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, time, "88", "INBOUND_ORDER", "101", 2L, 1L, "00000000-0000-4000-8000-000000000099", "delivery-101", null, node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java index 945887023..3c57ff84b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.firstleg.entity.InboundHoldRecordEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.ContainerEventInboxEntity; @@ -82,8 +81,7 @@ class OverseasInboundOrderDetainedConsumerTest { "LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z")); var node = objectMapper.valueToTree(payload); return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version), - EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(), - RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L, + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(), "88", "INBOUND_ORDER", "1001", version, 1L, "00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); } diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index b6a5b83b3..d7ad899ca 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -10,7 +10,7 @@ - 应用:`BaiyueGatewayApplication.java` - Security/访问域:`config/GatewaySecurityConfig.java`、`AppScopeAuthorizationManager.java` - 会话/CORS/请求头:`config/RedisTokenSessionWebFilter.java`、`GatewayCorsConfiguration.java`、`filter/TrustedIdentityHeaderFilter.java`、`CorrelationIdWebFilter.java` -- 路由配置:`src/main/resources/application*.yml`、`../config/nacos/{local,cn}/gateway.yml` +- 路由配置:`src/main/resources/application*.yml`、`../config/nacos/test/gateway.yml` - 测试:`src/test/java/com/baiyue/gateway/`;`mvn -pl gateway -am test` ## 修改约束与分期 diff --git a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java index 96a594ea1..e5f04ed5e 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java @@ -20,6 +20,17 @@ public class RedisTokenSessionWebFilter implements WebFilter { @Resource private ReactiveStringRedisTemplate reactiveStringRedisTemplate; @Resource private GatewaySecurityProperties properties; @Resource private ObjectMapper objectMapper; + @org.springframework.beans.factory.annotation.Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") + private String validationUri; + @org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token}") + private String internalToken; + private final org.springframework.web.reactive.function.client.WebClient stateClient = + org.springframework.web.reactive.function.client.WebClient.builder() + .clientConnector(new org.springframework.http.client.reactive.ReactorClientHttpConnector( + reactor.netty.http.client.HttpClient.create() + .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000) + .responseTimeout(java.time.Duration.ofSeconds(4)).followRedirect(false))) + .build(); @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { @@ -34,7 +45,19 @@ public class RedisTokenSessionWebFilter implements WebFilter { WebFilterChain chain) { String jwtId = jwt.getToken().getId(); if (jwtId == null || jwtId.isBlank()) return unauthorized(exchange); + Object userId = jwt.getToken().getClaims().get("user_id"); + if (userId == null) return unauthorized(exchange); return reactiveStringRedisTemplate.hasKey(properties.accessTokenKey(jwtId)) + .flatMap(active -> Boolean.TRUE.equals(active) + ? stateClient.post().uri(validationUri) + .header("X-Internal-Service-Token", internalToken) + .bodyValue(java.util.Map.of("jwtId", jwtId, "userId", userId)) + .retrieve().bodyToMono(com.fasterxml.jackson.databind.JsonNode.class) + .map(body -> body.path("code").isTextual() && "200".equals(body.path("code").asText()) + && body.path("data").isBoolean() && body.path("data").booleanValue()) + .defaultIfEmpty(false) + : Mono.just(false)) + .timeout(java.time.Duration.ofSeconds(4)) .onErrorReturn(false) .flatMap(active -> Boolean.TRUE.equals(active) ? chain.filter(exchange) : unauthorized(exchange)); diff --git a/gateway/src/main/resources/application-cn.yml b/gateway/src/main/resources/application-cn.yml deleted file mode 100644 index d958fc845..000000000 --- a/gateway/src/main/resources/application-cn.yml +++ /dev/null @@ -1,10 +0,0 @@ -spring: - config: - activate: - on-profile: cn - -baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:cn} - data-residency: ${DATA_RESIDENCY:CN} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index c7df24b36..94758a195 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} group: dev: cn test: cn @@ -14,27 +14,27 @@ spring: - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true data: redis: - host: ${REDIS_HOST:127.0.0.1} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} timeout: ${REDIS_TIMEOUT:3s} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} gateway: discovery: locator: @@ -157,12 +157,6 @@ spring: - Path=/integration-service/v3/api-docs/** filters: - StripPrefix=1 - - id: event-bridge-openapi - uri: lb://event-bridge-service - predicates: - - Path=/event-bridge-service/v3/api-docs/** - filters: - - StripPrefix=1 - id: finance-openapi uri: lb://finance-service predicates: @@ -178,7 +172,7 @@ baiyue: allow-credentials: ${CORS_ALLOW_CREDENTIALS:false} max-age: ${CORS_MAX_AGE:3600} security: - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} @@ -203,7 +197,6 @@ baiyue: - /firstleg-service/v3/api-docs/** - /audit-service/v3/api-docs/** - /integration-service/v3/api-docs/** - - /event-bridge-service/v3/api-docs/** - /finance-service/v3/api-docs/** - /lastleg-service/v3/api-docs/** - /warehouse-overseas-service/v3/api-docs/** @@ -251,8 +244,6 @@ springdoc: url: /audit-service/v3/api-docs - name: integration url: /integration-service/v3/api-docs - - name: event-bridge - url: /event-bridge-service/v3/api-docs - name: finance url: /finance-service/v3/api-docs - name: lastleg diff --git a/gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java b/gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java new file mode 100644 index 000000000..006c34f9d --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/RedisTokenSessionWebFilterTest.java @@ -0,0 +1,58 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RedisTokenSessionWebFilterTest { + @Test void checksAuthorityAsynchronouslyAndFailsClosed() throws Exception { + var response = new AtomicReference<>("{\"code\":\"200\",\"data\":true}"); + var bearer = new AtomicReference(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/validate", exchange -> { + bearer.set(exchange.getRequestHeaders().getFirst("Authorization")); + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + var filter = new RedisTokenSessionWebFilter(); + var redis = mock(ReactiveStringRedisTemplate.class); + when(redis.hasKey("test:access:jti")).thenReturn(Mono.just(true)); + ReflectionTestUtils.setField(filter, "reactiveStringRedisTemplate", redis); + ReflectionTestUtils.setField(filter, "properties", new GatewaySecurityProperties(null, "issuer", "aud", "test", List.of())); + ReflectionTestUtils.setField(filter, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(filter, "validationUri", "http://127.0.0.1:" + server.getAddress().getPort() + "/validate"); + ReflectionTestUtils.setField(filter, "internalToken", "internal"); + try { + for (boolean accepted : new boolean[]{true, false}) { + response.set("{\"code\":\"200\",\"data\":" + accepted + "}"); + var jwt = Jwt.withTokenValue("token").header("alg", "RS256").claim("jti", "jti").claim("user_id", 1L).build(); + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/orders")) + .mutate().principal(Mono.just(new JwtAuthenticationToken(jwt))).build(); + var invoked = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange, request -> { invoked.set(true); return Mono.empty(); })) + .expectComplete().verify(Duration.ofSeconds(5)); + assertThat(invoked.get()).isEqualTo(accepted); + if (!accepted) assertThat(exchange.getResponse().getStatusCode().value()).isEqualTo(401); + assertThat(bearer.get()).isNull(); + } + } finally { server.stop(0); } + } +} diff --git a/iam/AGENTS.md b/iam/AGENTS.md index e8e8b4c46..d7663b79e 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -18,7 +18,7 @@ | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | -| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | +| 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -32,8 +32,8 @@ - 注册创建时可用同一手机号初始化公司联系电话与首位管理员登录手机号,但此后两者独立:企业资料更新不得探测 Auth 唯一性或修改任何成员账号;成员登录手机号修改必须检查 Auth 唯一性、同步账号并撤销该账号会话。企业审核不得把法人身份证恢复为必填资质。 - 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 -- 国内头程 PDA 使用 `CN_FIRSTLEG_PDA_OPERATOR`,海外仓 PDA 使用 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`;两个角色互斥且权限不得交叉。只有海外角色可保存 `overseasWarehouseId`,且必须为单个有效海外仓;国内角色及其他角色必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 -- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 +- 国内头程 PDA 使用 `CN_FIRSTLEG_PDA_OPERATOR`,海外仓 PDA 使用 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`;两个角色互斥且权限不得交叉。只有海外角色可保存 `overseasWarehouseId`,且必须为单个有效海外仓;国内角色及其他角色必须为空。仓绑定、账号状态、成员角色与角色/权限状态变化必须在同一业务事务内推进持久化 subject 版本,客户状态推进 tenant 版本。`SecurityVersionService` 强制 MANDATORY 事务;不能用提交前 Redis 删除或 afterCommit 回调代替持久化版本。 +- `AuthorizationQueryMapper.findSnapshot` 单条 SELECT 同时读取有效身份、角色权限、两类仓绑定、subject/tenant 版本,避免跨语句混合快照。客户 `DISABLED`/`ARCHIVED`、成员或用户停用/删除拒绝授权;建档审核状态继续允许登录完善资料,业务资格仍由对应服务校验。无角色/权限的有效主体保留空集合,不能因 LEFT JOIN 丢失身份。共享角色变更须枚举全部关联客户成员。 ## 全量验收导航 diff --git a/iam/pom.xml b/iam/pom.xml index 20770d35f..7e758f9f3 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -5,7 +5,6 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml iam-service - com.baiyue.sharedcommon-event-contract${project.version} com.baiyue.sharedbaiyue-common-audit${project.version} org.apache.rocketmqrocketmq-spring-boot-starter com.baiyue.sharedbaiyue-common-openapi${project.version} diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java deleted file mode 100644 index c68eac775..000000000 --- a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.baiyue.iam.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient", - url = "${baiyue.security-event.event-bridge-url:}") -public interface SecurityEventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 727c6991b..cdb94e201 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -42,6 +42,14 @@ public final class AuthorizationDtos { private Long permissionVersion; private Long overseasWarehouseId; private Long domesticWarehouseId; + private Long tenantVersion; + + public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion, Long overseasWarehouseId, Long domesticWarehouseId) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L); + } public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, String clientType, Set roles, Set permissions, diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java new file mode 100644 index 000000000..15bb73804 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationSnapshotRow.java @@ -0,0 +1,14 @@ +package com.baiyue.iam.dto; + +import lombok.Data; + +/** Rows from one MVCC statement; all identity/authorization facts share one read view. */ +@Data +public class AuthorizationSnapshotRow { + private String roleCode; + private String permissionCode; + private Long permissionVersion; + private Long tenantVersion; + private Long overseasWarehouseId; + private Long domesticWarehouseId; +} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 2f83481d9..42a525c93 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -12,105 +12,27 @@ import java.util.List; @Mapper public interface AuthorizationQueryMapper { @Select(""" - SELECT COUNT(1) - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - WHERE u.auth_user_id = #{userId} - AND u.status = 'ACTIVE' AND u.deleted = 0 - AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} - AND m.status = 'ACTIVE' AND m.deleted = 0 - AND (m.tenant_id = 0 OR EXISTS ( - SELECT 1 FROM iam_tenant t WHERE t.id = m.tenant_id AND t.deleted = 0 - AND t.status IN ('ACTIVE', 'PROFILE_REQUIRED', 'PENDING', 'REJECTED') - )) + SELECT r.role_code AS roleCode, p.permission_code AS permissionCode, + COALESCE(sv.permission_version,0) AS permissionVersion, + COALESCE(tv.status_version,0) AS tenantVersion, + m.overseas_warehouse_id AS overseasWarehouseId, + m.domestic_warehouse_id AS domesticWarehouseId + FROM iam_user u + JOIN iam_membership m ON m.user_id=u.id + LEFT JOIN iam_tenant t ON t.id=m.tenant_id AND t.deleted=0 + LEFT JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id + LEFT JOIN iam_role r ON r.id=mr.role_id AND (r.tenant_id=0 OR r.tenant_id=m.tenant_id) + AND r.app_code=#{appCode} AND r.status='ACTIVE' AND r.deleted=0 + LEFT JOIN iam_role_permission rp ON rp.role_id=r.id + LEFT JOIN iam_permission p ON p.id=rp.permission_id AND (p.tenant_id=0 OR p.tenant_id=m.tenant_id) + AND p.app_code=#{appCode} AND p.status='ACTIVE' AND p.deleted=0 + LEFT JOIN iam_security_subject_version sv ON sv.auth_user_id=u.auth_user_id + LEFT JOIN iam_security_tenant_version tv ON tv.tenant_id=m.tenant_id + WHERE u.auth_user_id=#{userId} AND u.status='ACTIVE' AND u.deleted=0 + AND m.id=#{membershipId} AND m.tenant_id=#{tenantId} AND m.status='ACTIVE' AND m.deleted=0 + AND (m.tenant_id=0 OR t.status IN ('ACTIVE','PROFILE_REQUIRED','PENDING','REJECTED')) """) - int countActiveIdentity(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("membershipId") long membershipId); - - @Select(""" - SELECT DISTINCT r.role_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - WHERE u.auth_user_id = #{userId} - AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} - AND r.status = 'ACTIVE' AND r.deleted = 0 - ORDER BY r.role_code - """) - List findRoles(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("membershipId") long membershipId, - @Param("appCode") String appCode); - - @Select(""" - SELECT DISTINCT p.permission_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - JOIN iam_role_permission rp ON rp.role_id = r.id - JOIN iam_permission p ON p.id = rp.permission_id - WHERE u.auth_user_id = #{userId} - AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 - AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) - AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 - ORDER BY p.permission_code - """) - List findPermissions(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("membershipId") long membershipId, - @Param("appCode") String appCode); - - @Select(""" - SELECT DISTINCT r.role_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 - AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 - ORDER BY r.role_code - """) - List findRolesByIdentity(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("appCode") String appCode); - - @Select(""" - SELECT DISTINCT p.permission_code - FROM iam_user u - JOIN iam_membership m ON m.user_id = u.id - JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id - JOIN iam_role r ON r.id = mr.role_id - JOIN iam_role_permission rp ON rp.role_id = r.id - JOIN iam_permission p ON p.id = rp.permission_id - WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 - AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 - AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) - AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 - AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) - AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 - ORDER BY p.permission_code - """) - List findPermissionsByIdentity(@Param("userId") long userId, - @Param("tenantId") long tenantId, - @Param("appCode") String appCode); - - @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") - long findPermissionVersion(@Param("userId") long userId); - - @Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") - Long findOverseasWarehouseId(@Param("tenantId") long tenantId, - @Param("membershipId") long membershipId); - - @Select("SELECT domestic_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") - Long findDomesticWarehouseId(@Param("tenantId") long tenantId, - @Param("membershipId") long membershipId); + List findSnapshot( + @Param("userId") long userId, @Param("tenantId") long tenantId, + @Param("membershipId") long membershipId, @Param("appCode") String appCode); } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java index e16dda80a..89d4d4059 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/IamMembershipRoleMapper.java @@ -19,7 +19,8 @@ public interface IamMembershipRoleMapper extends BaseMapper { FROM iam_membership_role mr JOIN iam_membership m ON m.id = mr.membership_id AND m.tenant_id = mr.tenant_id JOIN iam_user u ON u.id = m.user_id - WHERE mr.tenant_id = #{tenantId} + JOIN iam_role r ON r.id = mr.role_id + WHERE (#{tenantId} = 0 OR r.tenant_id = #{tenantId}) AND mr.role_id = #{roleId} AND m.deleted = 0 AND u.deleted = 0 diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index f1e524e2a..3affec32d 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -69,7 +69,7 @@ public class RbacRepository { public Set findRoleIdsByPermission(long tenantId, Long permissionId) { List rows = rolePermissionMapper.selectList( new LambdaQueryWrapper() - .eq(IamRolePermission::getTenantId, tenantId) + .eq(tenantId != 0, IamRolePermission::getTenantId, tenantId) .eq(IamRolePermission::getPermissionId, permissionId)); Set result = new LinkedHashSet<>(); rows.forEach(row -> result.add(row.getRoleId())); diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index 6bfca6f98..e41fa12f6 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -24,7 +24,7 @@ public class AuthorizationSnapshotService { private AuthorizationQueryMapper mapper; - @Transactional(readOnly = true) + @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public SnapshotResponse load(SnapshotRequest request) { requireRequest(request); String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT); @@ -37,25 +37,24 @@ public class AuthorizationSnapshotService { if ("PLATFORM".equals(appCode) && request.getTenantId() != 0) { throw new BizException(ResultCode.FORBIDDEN, "平台账号必须属于平台租户"); } - if (mapper.countActiveIdentity(request.getUserId(), request.getTenantId(), request.getMembershipId()) == 0) { + var rows = mapper.findSnapshot(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode); + if (rows.isEmpty()) { throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效"); } - Set roles = normalizedSet(mapper.findRoles( - request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); - Set permissions = normalizedSet(mapper.findPermissions( - request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); + Set roles = normalizedSet(rows.stream().map(com.baiyue.iam.dto.AuthorizationSnapshotRow::getRoleCode).toList()); + Set permissions = normalizedSet(rows.stream().map(com.baiyue.iam.dto.AuthorizationSnapshotRow::getPermissionCode).toList()); + var identity = rows.get(0); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), - mapper.findPermissionVersion(request.getUserId()), - mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId()), - mapper.findDomesticWarehouseId(request.getTenantId(), request.getMembershipId())); + identity.getPermissionVersion(), identity.getOverseasWarehouseId(), + identity.getDomesticWarehouseId(), identity.getTenantVersion()); } /** * 根据已认证请求主体重新计算有效权限,而不是复用 JWT 中可能已经过期的权限声明。 */ - @Transactional(readOnly = true) + @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Set currentPermissions(AuthenticatedUser user, String appCode) { if (user == null || user.userId() == null || user.tenantId() == null || user.membershipId() == null) { throw new BizException(ResultCode.UNAUTHORIZED, "Authentication identity is incomplete / 登录身份不完整"); diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 43f79000e..0779a61c8 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -41,7 +41,7 @@ public class MemberService { @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -106,8 +106,7 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); - securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE"); - securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + securityVersions.subjectChanged(authId); return response(tenantId, membership, user); } catch (RuntimeException exception) { try { @@ -146,6 +145,7 @@ public class MemberService { if (!new HashSet<>(currentRoleIds).equals(new HashSet<>(request.roleIds()))) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); + securityVersions.subjectChanged(user.getAuthUserId()); } return response(tenantId, membership); } @@ -169,9 +169,7 @@ public class MemberService { membership.setStatus(request.status()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); - securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status()); - securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT", - "MEMBER_STATUS_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 2b2748c2b..de62ec41b 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -43,7 +43,7 @@ public class MerchantOnboardingService { @Resource private IamTenantReviewMapper reviewMapper; @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; - @Resource private SecurityEventOutboxService securityEvents; + @Resource private SecurityVersionService securityVersions; @Resource private CustomerCodeRepository customerCodes; @Transactional @@ -82,7 +82,7 @@ public class MerchantOnboardingService { tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); - securityEvents.tenantStatus(tenantId, tenant.getStatus()); + securityVersions.tenantChanged(tenantId); IamUser user = new IamUser(); user.setId(userId); @@ -114,8 +114,7 @@ public class MerchantOnboardingService { membershipRole.setTenantId(tenantId); membershipRole.setCreatedAt(LocalDateTime.now()); membershipRoleMapper.insert(membershipRole); - securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE"); - securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + securityVersions.subjectChanged(request.authUserId()); if (StringUtils.hasText(request.address())) { IamTenantAddress address = new IamTenantAddress(); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 8f94a889d..abc0e4e32 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -39,7 +39,7 @@ public class PlatformInternalUserService { @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -77,7 +77,7 @@ public class PlatformInternalUserService { repository.insertUser(user); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); - publishInitialSecurityProjection(user); + initializeSecurityVersion(user); return response(membership, user, request.username(), roleIds); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -116,6 +116,7 @@ public class PlatformInternalUserService { repository.save(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); if (rolesChanged && !displayNameChanged) revokeSessions(user.getAuthUserId()); + if (rolesChanged) securityVersions.subjectChanged(user.getAuthUserId()); } catch (RuntimeException localFailure) { if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure); throw localFailure; @@ -146,9 +147,7 @@ public class PlatformInternalUserService { user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); - securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); - securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", - "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); return response(membership, user, authAccount.username(), roleIds); } catch (RuntimeException localFailure) { compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); @@ -178,8 +177,7 @@ public class PlatformInternalUserService { repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) { revokeSessions(user.getAuthUserId()); - securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", - "MEMBER_ROLES_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); return response(membership, user, authAccount.username(), roleIds); @@ -204,14 +202,12 @@ public class PlatformInternalUserService { IamMembership membership = newMembership(membershipId, user.getId(), memberNo); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); - publishInitialSecurityProjection(user); + initializeSecurityVersion(user); return response(membership, user, authAccount.username(), roleIds); } - private void publishInitialSecurityProjection(IamUser user) { - securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus()); - securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", - "INITIAL_AUTHORIZATION"); + private void initializeSecurityVersion(IamUser user) { + securityVersions.subjectChanged(user.getAuthUserId()); } private InternalUserResponse response(IamMembership membership) { diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 93f0dfe29..060a53a05 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -44,7 +44,7 @@ public class PlatformUserManagementService { @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -107,8 +107,7 @@ public class PlatformUserManagementService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); members.insert(membership); members.replaceRoles(tenantId, membershipId, roleIds); - securityEvents.userStatus(authUserId, tenantId, appCode, request.status()); - securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION"); + securityVersions.subjectChanged(authUserId); return response(membership); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -170,8 +169,7 @@ public class PlatformUserManagementService { new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } if (rolesChanged || warehouseChanged || scopeChanged) { - securityEvents.permissionChanged(user.getAuthUserId(), newTenantId, - appCode(newTenantId), "PLATFORM_USER_AUTHORIZATION_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } } catch (RuntimeException localFailure) { compensateUpdate(user.getAuthUserId(), membershipId, oldTenantId, oldAccount, @@ -204,10 +202,7 @@ public class PlatformUserManagementService { user.setStatus(request.status()); members.saveUser(user); members.save(membership); - securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), - appCode(membership.getTenantId()), request.status()); - securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(), - appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } catch (RuntimeException localFailure) { call(() -> authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldAccount.status()))); diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index 6f1669ee6..f30f728ce 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -33,7 +33,7 @@ public class RbacService { @Resource private RbacRepository repository; @Resource private MemberRepository memberRepository; @Resource private AuthAccountClient authClient; - @Resource private SecurityEventOutboxService securityEvents; + @Resource private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -186,6 +186,7 @@ public class RbacService { affectedAccounts.addAll(repository.findAssignedAuthUserIds(tenantId, roleId)); } revokeSessions(List.copyOf(affectedAccounts)); + affectedAccounts.forEach(id -> securityVersions.subjectChanged(id)); return permission; } @@ -240,7 +241,7 @@ public class RbacService { var user = memberRepository.findUser(membership.getUserId()); if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable(); revokeSessions(List.of(user.getAuthUserId())); - securityEvents.permissionChanged(user.getAuthUserId(), tenantId, appCode, "MEMBER_ROLES_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } } @@ -260,12 +261,13 @@ public class RbacService { repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds()); List affected = repository.findAssignedAuthUserIds(tenantId, request.roleId()); revokeSessions(affected); - affected.forEach(userId -> securityEvents.permissionChanged(userId, tenantId, appCode, - "ROLE_PERMISSIONS_CHANGED")); + affected.forEach(userId -> securityVersions.subjectChanged(userId)); } private void revokeAssignedSessions(long tenantId, Long roleId) { - revokeSessions(repository.findAssignedAuthUserIds(tenantId, roleId)); + List affected = repository.findAssignedAuthUserIds(tenantId, roleId); + revokeSessions(affected); + affected.forEach(id -> securityVersions.subjectChanged(id)); } private void revokeSessions(List accountIds) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java deleted file mode 100644 index de41c5d9d..000000000 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventEnvelopeValidator; -import com.baiyue.common.event.RegionCode; -import com.baiyue.iam.client.SecurityEventBridgeClient; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -/** IAM 安全事件至少一次投递器,支持过期租约回收、退避重试和死信。 */ -@Component -@Slf4j -public class SecurityEventOutboxRelay { - @Resource private JdbcTemplate jdbc; - @Resource private ObjectMapper objectMapper; - @Resource private SecurityEventBridgeClient client; - @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.security-event.max-attempts:12}") private int maxAttempts; - private final String workerId = UUID.randomUUID().toString(); - - @Scheduled(fixedDelayString = "${baiyue.security-event.fixed-delay-ms:5000}") - public void relay() { - recoverExpiredLeases(); - for (String eventId : candidates()) deliver(eventId); - } - - @Transactional - public void deliver(String eventId) { - LocalDateTime now = LocalDateTime.now(); - int claimed = jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?," - + "lease_until=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", - workerId, now.plusMinutes(2), now, eventId, now); - if (claimed != 1) return; - String json = jdbc.queryForObject("SELECT payload_json FROM iam_security_event_outbox " - + "WHERE event_id=? AND status='SENDING' AND lease_owner=?", String.class, eventId, workerId); - try { - EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); - EventEnvelopeValidator.requireValid(envelope); - if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { - throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS"); - } - var response = client.receive(internalToken, envelope); - if (response == null || !response.successful() || response.data() == null - || !eventId.equals(response.data().eventId())) { - throw new IllegalStateException("事件桥未确认 IAM 安全事件"); - } - jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=?,lease_owner=NULL," - + "lease_until=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND lease_owner=?", - LocalDateTime.now(), LocalDateTime.now(), eventId, workerId); - } catch (Exception exception) { - fail(eventId, exception.getMessage()); - log.warn("IAM 安全事件投递失败,eventId={}, reason={}", eventId, exception.getMessage()); - } - } - - private List candidates() { - return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE status='PENDING' " - + "AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); - } - - private void recoverExpiredLeases() { - jdbc.update("UPDATE iam_security_event_outbox SET status='PENDING',lease_owner=NULL,lease_until=NULL," - + "next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND lease_until= maxAttempts ? "DEAD" : "PENDING"; - long delay = Math.min(1800, 5L << Math.min(next, 8)); - jdbc.update("UPDATE iam_security_event_outbox SET status=?,attempt_count=?,next_retry_at=?," - + "lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=? WHERE event_id=? AND lease_owner=?", - status, next, LocalDateTime.now().plusSeconds(delay), truncate(message), LocalDateTime.now(), eventId, workerId); - } - - private String truncate(String value) { - String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " "); - return text.length() > 1000 ? text.substring(0, 1000) : text; - } -} diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java deleted file mode 100644 index 6d5266658..000000000 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventIdentifiers; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.baiyue.iam.mapper.AuthorizationQueryMapper; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */ -@Service -public class SecurityEventOutboxService { - private static final List OVERSEAS_AUTHORITY_PREFIXES = List.of( - "platform:overseas-warehouse:", "platform:overseas-business:", - "platform:overseas-master-data:", "merchant:warehouse:", "merchant:overseas-warehouse:", - "merchant:cross-border:", "platform:finance:", "lastleg:"); - @Resource private JdbcTemplate jdbc; - @Resource private ObjectMapper objectMapper; - @Resource private AuthorizationQueryMapper authorizationQueries; - - public void userStatus(long authUserId, long tenantId, String appCode, String status) { - long version = bump(authUserId); - write(EventTypes.IAM_USER_STATUS_CHANGED, "USER", String.valueOf(authUserId), tenantId, version, - new IamSecurityEventsV1.UserStatusChanged(authUserId, tenantId, appCode, status, version, Instant.now())); - } - - public void tenantStatus(long tenantId, String status) { - jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) " - + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); - long version = jdbc.queryForObject( - "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); - String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED"; - write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, - new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now())); - } - - public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { - long version = bump(authUserId); - Set roles = Set.copyOf(authorizationQueries.findRolesByIdentity(authUserId, tenantId, appCode)); - Set authorities = authorizationQueries.findPermissionsByIdentity(authUserId, tenantId, appCode) - .stream().filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream() - .anyMatch(permission::startsWith)).collect(Collectors.toUnmodifiableSet()); - write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version, - new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, - Instant.now(), roles, authorities)); - } - - private long bump(long authUserId) { - jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) " - + "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); - return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", - Long.class, authUserId); - } - - private void write(String type, String aggregateType, String aggregateId, long tenantId, - long aggregateVersion, Object value) { - JsonNode payload = objectMapper.valueToTree(value); - String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); - String flowId = EventIdentifiers.newUuid(); - EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION, - Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType, - aggregateId, aggregateVersion, null, flowId, flowId, null, payload, hash).requireValid(); - try { - jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId, - objectMapper.writeValueAsString(envelope)); - } catch (Exception exception) { - throw new IllegalStateException("IAM 安全事件写入失败", exception); - } - } -} diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java new file mode 100644 index 000000000..c8347b289 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityVersionService.java @@ -0,0 +1,24 @@ +package com.baiyue.iam.service; + +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** Durable security epochs commit or roll back with their IAM mutation, with no projection relay. */ +@Service +@Transactional(propagation = Propagation.MANDATORY) +public class SecurityVersionService { + @Resource private JdbcTemplate jdbc; + + public void subjectChanged(long authUserId) { + jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); + } + + public void tenantChanged(long tenantId) { + jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 811857965..6c5324b5a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -44,7 +44,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni @Resource private AuthAccountClient authClient; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Value("${baiyue.security.internal-service-token}") private String internalToken; private SecureRandom random = new SecureRandom(); @@ -115,7 +115,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni membership.setUpdatedBy(operatorId); members.insert(membership); members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId())); - publishInitialSecurityProjection(authUserId, tenant.getId()); + initializeSecurityVersion(authUserId, tenant.getId()); return result; } catch (RuntimeException localFailure) { compensate(context, result); @@ -156,7 +156,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni String password = temporaryPassword(); String username = createAuthAccount(user.getAuthUserId(), password, tenant, owner.getId(), user.getPhone(), user.getDisplayName()); - publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); + initializeSecurityVersion(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } if (!"ACTIVE".equals(account.status()) @@ -171,8 +171,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni List repairedRoleIds = new java.util.ArrayList<>(roleIds); repairedRoleIds.add(ownerRole.getId()); members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList()); - securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", - "MEMBER_ROLES_CHANGED"); + securityVersions.subjectChanged(user.getAuthUserId()); } return ProvisioningResult.existing(); } @@ -232,9 +231,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni } } - private void publishInitialSecurityProjection(long authUserId, long tenantId) { - securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE"); - securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + private void initializeSecurityVersion(long authUserId, long tenantId) { + securityVersions.subjectChanged(authUserId); } private void compensate(long authUserId) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index f6a849271..07c565a85 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -76,7 +76,7 @@ public class TenantService { @Resource private TenantAdministratorProvisioningService administratorProvisioning; @Resource - private SecurityEventOutboxService securityEvents; + private SecurityVersionService securityVersions; @Transactional @BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT", @@ -194,7 +194,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); - securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); + securityVersions.tenantChanged(tenant.getId()); dimensionRuleInitialization.initialize(tenant.getId(), operatorId); overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName()); var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId); @@ -302,7 +302,7 @@ public class TenantService { tenant.setApprovedAt(approve ? LocalDateTime.now() : null); tenant.setApprovedBy(approve ? reviewerId : null); repository.saveTenant(tenant); - securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); + securityVersions.tenantChanged(tenant.getId()); for (var qualification : profileRepository.findQualifications(tenant.getId())) { if ("PENDING".equals(qualification.getStatus())) { qualification.setStatus(approve ? "APPROVED" : "REJECTED"); @@ -424,7 +424,7 @@ public class TenantService { tenant.setStatus(request.status()); repository.saveTenant(tenant); revokeMerchantSessions(tenantId); - securityEvents.tenantStatus(tenantId, request.status()); + securityVersions.tenantChanged(tenantId); return response(tenant); } diff --git a/iam/src/main/resources/application-dev.yml b/iam/src/main/resources/application-dev.yml deleted file mode 100644 index de7343f90..000000000 --- a/iam/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${IAM_DB_USERNAME:root} - password: ${IAM_DB_PASSWORD:123456} diff --git a/iam/src/main/resources/application-test.yml b/iam/src/main/resources/application-test.yml deleted file mode 100644 index 9566a4810..000000000 --- a/iam/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${IAM_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${IAM_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index a23c9fe33..66e01f4bf 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -2,29 +2,28 @@ server: port: ${IAM_PORT:9201} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: iam-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue: security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} customer-dimension-initialization: fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} @@ -36,6 +35,5 @@ baiyue: auth: url: ${AUTH_SERVICE_URL:} security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/iam/src/main/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql deleted file mode 100644 index ec4693cdb..000000000 --- a/iam/src/main/resources/db/security-event-outbox.sql +++ /dev/null @@ -1,35 +0,0 @@ -USE baiyue_iam; - -CREATE TABLE IF NOT EXISTS iam_security_subject_version ( - auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', - permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (auth_user_id) -) ENGINE=InnoDB COMMENT='安全主体权限版本'; - -CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( - tenant_id BIGINT NOT NULL COMMENT '客户ID', - status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (tenant_id) -) ENGINE=InnoDB COMMENT='客户状态事件版本'; - -CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( - event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', - event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型', - aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', - aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识', - payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', - status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', - attempt_count INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数', - next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', - lease_owner VARCHAR(128) NULL COMMENT '租约持有者', - lease_until DATETIME(3) NULL COMMENT '租约到期时间', - last_error VARCHAR(1000) NULL COMMENT '最近一次错误', - sent_at DATETIME(3) NULL COMMENT '发送成功时间', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (event_id), - KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), - KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) -) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱'; diff --git a/iam/src/main/resources/db/security-version.sql b/iam/src/main/resources/db/security-version.sql new file mode 100644 index 000000000..6c77203b8 --- /dev/null +++ b/iam/src/main/resources/db/security-version.sql @@ -0,0 +1,16 @@ +USE baiyue_iam; + +CREATE TABLE IF NOT EXISTS iam_security_subject_version ( + auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', + permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (auth_user_id) +) ENGINE=InnoDB COMMENT='安全主体权限版本'; + +CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( + tenant_id BIGINT NOT NULL COMMENT '客户ID', + status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (tenant_id) +) ENGINE=InnoDB COMMENT='客户状态持久化版本'; + diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 63643eabb..e893dee83 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -869,6 +869,37 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/authorization-snapshot: + post: + tags: [IAM Resources / IAM 资源] + summary: Read a consistent authoritative authorization snapshot + description: Internal service credential required; no public Gateway route. Identity, permissions, warehouse bindings and monotonic subject/customer versions are read by one MVCC SELECT statement, without cross-statement snapshot mixing. Auth fingerprints this state for login, refresh and every Access validation. + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [userId, tenantId, membershipId, appCode] + properties: + userId: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + membershipId: {type: integer, format: int64} + appCode: {type: string, enum: [PLATFORM, MERCHANT]} + responses: + '200': + description: Consistent snapshot or business error + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/AuthorizationSnapshot'} /internal/iam/merchant-registrations/{authUserId}: get: tags: [IAM Resources / IAM 资源] @@ -1747,3 +1778,18 @@ components: properties: roleId: {type: integer, format: int64} permissionIds: {type: array, items: {type: integer, format: int64}} + AuthorizationSnapshot: + type: object + required: [userId, tenantId, membershipId, appCode, clientType, roles, permissions, permissionVersion, tenantVersion] + properties: + userId: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + membershipId: {type: integer, format: int64} + appCode: {type: string, enum: [PLATFORM, MERCHANT]} + clientType: {type: string} + roles: {type: array, items: {type: string}} + permissions: {type: array, items: {type: string}} + permissionVersion: {type: integer, format: int64, description: Persistent subject security epoch} + tenantVersion: {type: integer, format: int64, description: Persistent customer status epoch} + overseasWarehouseId: {type: integer, format: int64, nullable: true} + domesticWarehouseId: {type: integer, format: int64, nullable: true} diff --git a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java deleted file mode 100644 index 92692f92d..000000000 --- a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.baiyue.iam.client; - -import org.junit.jupiter.api.Test; -import org.springframework.cloud.openfeign.FeignClient; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class SecurityEventBridgeClientContractTest { - private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); - - @Test - void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() { - FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class); - - assertThat(annotation.name()).isEqualTo("event-bridge-service"); - assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}"); - } - - @Test - void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception { - String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml")); - String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml")); - String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); - - assertThat(application).contains( - "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); - assertThat(nacos).contains( - "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); - assertThat(compose).contains( - "SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}"); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java index d6d8047f4..d86f13c46 100644 --- a/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java +++ b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java @@ -12,26 +12,32 @@ class AuthorizationIdentitySqlTest { void disabledArchivedDeletedOrMissingCustomerCannotGetNewAuthorizationSnapshot() throws Exception { var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "")); jdbc.execute("CREATE TABLE iam_user(id BIGINT, auth_user_id BIGINT, status VARCHAR(30), deleted INT)"); - jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT, overseas_warehouse_id BIGINT, domestic_warehouse_id BIGINT)"); jdbc.execute("CREATE TABLE iam_tenant(id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),role_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_role_permission(role_id BIGINT,permission_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_permission(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),permission_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT,permission_version BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT,status_version BIGINT)"); jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)"); - jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0,NULL,NULL)"); jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)"); - String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("countActiveIdentity", long.class, long.class, long.class) - .getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7"); + String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("findSnapshot", long.class, long.class, long.class, String.class) + .getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7").replace("#{appCode}", "'MERCHANT'"); for (String status : new String[]{"ACTIVE", "PROFILE_REQUIRED", "PENDING", "REJECTED"}) { jdbc.update("UPDATE iam_tenant SET status=?", status); - assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isEqualTo(1); + assertThat(jdbc.queryForList(sql).size()).as(status).isEqualTo(1); } for (String status : new String[]{"DISABLED", "ARCHIVED", "UNKNOWN"}) { jdbc.update("UPDATE iam_tenant SET status=?", status); - assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isZero(); + assertThat(jdbc.queryForList(sql).size()).as(status).isZero(); } jdbc.update("UPDATE iam_tenant SET status='ACTIVE', deleted=1"); - assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + assertThat(jdbc.queryForList(sql).size()).isZero(); jdbc.update("DELETE FROM iam_tenant"); - assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + assertThat(jdbc.queryForList(sql).size()).isZero(); jdbc.update("UPDATE iam_membership SET tenant_id=0"); - assertThat(jdbc.queryForObject(sql.replace("m.tenant_id = 7", "m.tenant_id = 0"), Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForList(sql.replace("m.tenant_id=7", "m.tenant_id=0")).size()).isEqualTo(1); } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java deleted file mode 100644 index b4336c35d..000000000 --- a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.baiyue.iam.repository; - -import org.junit.jupiter.api.Test; -import org.springframework.core.io.ClassPathResource; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class SecurityEventSqlContractTest { - @Test - void securityOutboxHasVersionsAndReliableDeliveryColumns() throws Exception { - String sql = new ClassPathResource("db/security-event-outbox.sql") - .getContentAsString(StandardCharsets.UTF_8); - assertThat(sql) - .contains("iam_security_subject_version") - .contains("permission_version BIGINT NOT NULL DEFAULT 0") - .contains("iam_security_tenant_version") - .contains("status_version BIGINT NOT NULL DEFAULT 0") - .contains("iam_security_event_outbox") - .contains("payload_json JSON NOT NULL") - .contains("idx_security_outbox_publish") - .contains("PENDING", "SENDING", "SENT", "DEAD"); - } - - @Test - void cleanInitializationIncludesSecurityEventSchema() throws Exception { - Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); - Path migration = Files.exists(direct) - ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize(); - String sql = Files.readString(migration, StandardCharsets.UTF_8); - assertThat(sql) - .contains("CREATE TABLE iam_security_subject_version") - .contains("CREATE TABLE iam_security_tenant_version") - .contains("CREATE TABLE iam_security_event_outbox") - .contains("idx_security_outbox_publish", "idx_security_outbox_aggregate") - .contains("SELECT 'PASS' AS initialization_status") - .doesNotContain("assert_iam_security_event_schema", "CREATE TABLE IF NOT EXISTS iam_security_event"); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java new file mode 100644 index 000000000..2c59655fa --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/SecurityVersionSqlContractTest.java @@ -0,0 +1,26 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityVersionSqlContractTest { + @Test void versionsRemainDurableWithoutProjectionOutbox() throws Exception { + String sql = new ClassPathResource("db/security-version.sql").getContentAsString(StandardCharsets.UTF_8); + assertThat(sql).contains("iam_security_subject_version", "permission_version BIGINT NOT NULL DEFAULT 0", + "iam_security_tenant_version", "status_version BIGINT NOT NULL DEFAULT 0") + .doesNotContain("iam_security_event_outbox"); + } + + @Test void cleanInitializationIncludesVersionsWithoutRetiredProjectionTables() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path baseline = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + assertThat(Files.readString(baseline, StandardCharsets.UTF_8)) + .contains("CREATE TABLE iam_security_subject_version", "CREATE TABLE iam_security_tenant_version", + "SELECT 'PASS' AS initialization_status") + .doesNotContain("CREATE TABLE iam_security_event_outbox", "CREATE TABLE ll_security_event_inbox", "CREATE TABLE ll_security_projection"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 1c492f499..f8d83d00f 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -24,12 +24,7 @@ class AuthorizationSnapshotServiceTest { @Resource private AuthorizationSnapshotService service; @Test void loadsRolesAndPermissionsForActiveMerchantIdentity() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("ADMIN", "OPERATOR")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("order:read", "order:create")); - when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L); + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("ADMIN", "OPERATOR"), List.of("order:read", "order:create"))); var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); @@ -51,36 +46,29 @@ class AuthorizationSnapshotServiceTest { @Test void rejectsInactiveMembership() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(0); - - assertThatThrownBy(() -> service - .load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT"))) - .isInstanceOfSatisfying(BizException.class, - exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); - verify(mapper).countActiveIdentity(42L, 7L, 9L); + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of()); + assertThatThrownBy(() -> service.load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT"))) + .isInstanceOfSatisfying(BizException.class, + exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN)); + verify(mapper).findSnapshot(42L, 7L, 9L, "MERCHANT"); } @Test void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() { - when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR")); - when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")) - .thenReturn(List.of("platform:order:list")); + when(mapper.findSnapshot(42L, 0L, 9L, "PLATFORM")).thenReturn(rows(List.of("ORDER_OPERATOR"), List.of("platform:order:list"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); assertThat(result).containsExactly("platform:order:list"); - verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM"); + verify(mapper).findSnapshot(42L, 0L, 9L, "PLATFORM"); } @Test void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create")); + "merchant:warehouse:delivery:create"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); @@ -92,14 +80,12 @@ class AuthorizationSnapshotServiceTest { @Test void exposesCustomerOverseasBusinessPermissionsFromCurrentRoleAssignments() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( "merchant:cross-border:procurement:list", "merchant:cross-border:procurement:create", "merchant:cross-border:domestic-trucking:list", "merchant:cross-border:pallet-shipping:list", - "merchant:warehouse:inbound:list")); + "merchant:warehouse:inbound:list"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); @@ -114,11 +100,9 @@ class AuthorizationSnapshotServiceTest { @Test void exposesWalletPermissionsFromCurrentRoleAssignments() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of( + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( "merchant:finance:account:view", - "merchant:finance:recharge:create")); + "merchant:finance:recharge:create"))); var merchantPermissions = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); @@ -127,11 +111,9 @@ class AuthorizationSnapshotServiceTest { "merchant:finance:account:view", "merchant:finance:recharge:create"); - when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1); - when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN")); - when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of( + when(mapper.findSnapshot(51L, 0L, 19L, "PLATFORM")).thenReturn(rows(List.of("PLATFORM_ADMIN"), List.of( "platform:finance:recharge:review", - "platform:finance:customer-account:adjust")); + "platform:finance:customer-account:adjust"))); var platformPermissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L, "platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); @@ -143,11 +125,9 @@ class AuthorizationSnapshotServiceTest { @Test void exposesInboundHoldIndependentlyFromReadPermissions() { - when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1); - when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN")); - when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of( + when(mapper.findSnapshot(51L, 0L, 19L, "PLATFORM")).thenReturn(rows(List.of("PLATFORM_ADMIN"), List.of( "platform:firstleg:inbound:list", - "platform:firstleg:inbound:hold")); + "platform:firstleg:inbound:hold"))); var permissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L, "platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); @@ -168,9 +148,7 @@ class AuthorizationSnapshotServiceTest { @Test void shouldHandleCase01() { - when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null); - when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null); + when(mapper.findSnapshot(42L, 0L, 9L, "PLATFORM")).thenReturn(rows(null, null)); var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L, "operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"); @@ -180,12 +158,10 @@ class AuthorizationSnapshotServiceTest { @Test void newLoginPermissionsDisappearWhileRoleDisabledAndReturnAfterReenable() { - when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1); - when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("ORDER_VIEWER"), List.of(), List.of("ORDER_VIEWER")); - when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) - .thenReturn(List.of("merchant:order:list", "merchant:order:view"), List.of(), - List.of("merchant:order:list", "merchant:order:view")); + when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")) + .thenReturn(rows(List.of("ORDER_VIEWER"), List.of("merchant:order:list", "merchant:order:view")), + rows(List.of(), List.of()), + rows(List.of("ORDER_VIEWER"), List.of("merchant:order:list", "merchant:order:view"))); SnapshotRequest request = new SnapshotRequest(42L, 7L, 9L, "MERCHANT"); assertThat(service.load(request).getPermissions()) @@ -195,4 +171,16 @@ class AuthorizationSnapshotServiceTest { .containsExactlyInAnyOrder("merchant:order:list", "merchant:order:view"); } + private List rows(List roles, List permissions) { + var result = new java.util.ArrayList(); + List roleValues = roles == null || roles.isEmpty() ? java.util.Collections.singletonList(null) : roles; + List permissionValues = permissions == null || permissions.isEmpty() ? java.util.Collections.singletonList(null) : permissions; + for (String role : roleValues) for (String permission : permissionValues) { + var row = new com.baiyue.iam.dto.AuthorizationSnapshotRow(); + row.setRoleCode(role); row.setPermissionCode(permission); + row.setPermissionVersion(1L); row.setTenantVersion(1L); row.setOverseasWarehouseId(900L); + result.add(row); + } + return result; + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java new file mode 100644 index 000000000..bfad7d450 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotTransactionTest.java @@ -0,0 +1,90 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.lang.reflect.Proxy; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.assertj.core.api.Assertions.*; + +/** Production SQL and transaction annotation, with a commit between the atomic SQL read and response assembly. */ +class AuthorizationSnapshotTransactionTest { + @Test void singleStatementSnapshotCannotMixIdentityPermissionsAndVersion() throws Exception { + var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE iam_user(id BIGINT,auth_user_id BIGINT,status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership(id BIGINT,user_id BIGINT,tenant_id BIGINT,status VARCHAR(20),deleted INT,overseas_warehouse_id BIGINT,domestic_warehouse_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_tenant(id BIGINT,status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),role_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_permission(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),permission_code VARCHAR(20),status VARCHAR(20),deleted INT)"); + jdbc.execute("CREATE TABLE iam_role_permission(role_id BIGINT,permission_id BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT PRIMARY KEY,permission_version BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT PRIMARY KEY,status_version BIGINT)"); + jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0,100,NULL)"); + jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_role VALUES(2,7,'MERCHANT','OPERATOR','ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership_role VALUES(9,7,2)"); + jdbc.update("INSERT INTO iam_permission VALUES(3,7,'MERCHANT','write','ACTIVE',0)"); + jdbc.update("INSERT INTO iam_role_permission VALUES(2,3)"); + jdbc.update("INSERT INTO iam_security_subject_version VALUES(42,1)"); + jdbc.update("INSERT INTO iam_security_tenant_version VALUES(7,1)"); + var factory = new SqlSessionFactoryBean(); factory.setDataSource(ds); + var configuration = new org.apache.ibatis.session.Configuration(); configuration.addMapper(AuthorizationQueryMapper.class); + configuration.addMapper(com.baiyue.iam.mapper.IamMembershipRoleMapper.class); + factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(AuthorizationQueryMapper.class); + var read = new CountDownLatch(1); var committed = new CountDownLatch(1); var pause = new AtomicBoolean(true); + AuthorizationQueryMapper controlled = (AuthorizationQueryMapper) Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[]{AuthorizationQueryMapper.class}, (proxy, method, args) -> { + Object result = method.invoke(mapper, args); + if (method.getName().equals("findSnapshot") && pause.compareAndSet(true, false)) { + read.countDown(); if (!committed.await(5, TimeUnit.SECONDS)) throw new AssertionError("Commit barrier timed out"); + } + return result; + }); + var target = new AuthorizationSnapshotService(); ReflectionTestUtils.setField(target, "mapper", controlled); + var tx = new DataSourceTransactionManager(ds); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(tx, new AnnotationTransactionAttributeSource())); + var service = (AuthorizationSnapshotService) proxy.getProxy(); + var executor = Executors.newSingleThreadExecutor(); + try { + var previous = executor.submit(() -> service.load(new SnapshotRequest(42L,7L,9L,"MERCHANT"))); + assertThat(read.await(5, TimeUnit.SECONDS)).isTrue(); + new TransactionTemplate(tx).executeWithoutResult(status -> { + jdbc.update("UPDATE iam_permission SET permission_code='read' WHERE id=3"); + jdbc.update("UPDATE iam_security_subject_version SET permission_version=2 WHERE auth_user_id=42"); + jdbc.update("UPDATE iam_membership SET overseas_warehouse_id=200 WHERE id=9"); + }); + committed.countDown(); + var old = previous.get(5, TimeUnit.SECONDS); + assertThat(old.getPermissions()).containsExactly("write"); + assertThat(old.getPermissionVersion()).isEqualTo(1); assertThat(old.getOverseasWarehouseId()).isEqualTo(100); + var fresh = service.load(new SnapshotRequest(42L,7L,9L,"MERCHANT")); + assertThat(fresh.getPermissions()).containsExactly("read"); + assertThat(fresh.getPermissionVersion()).isEqualTo(2); assertThat(fresh.getOverseasWarehouseId()).isEqualTo(200); + // Shared platform role: enumerate both customers, not just membership tenant 0. + jdbc.update("INSERT INTO iam_user VALUES(2,43,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(10,2,8,'ACTIVE',0,NULL,NULL)"); + jdbc.update("INSERT INTO iam_role VALUES(5,0,'MERCHANT','SHARED','ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership_role VALUES(9,7,5),(10,8,5)"); + var assignments = new SqlSessionTemplate(factory.getObject()) + .getMapper(com.baiyue.iam.mapper.IamMembershipRoleMapper.class); + assertThat(assignments.findAssignedAuthUserIds(0, 5L)).containsExactly(42L, 43L); + assertThat(assignments.findAssignedAuthUserIds(7, 5L)).isEmpty(); + } finally { committed.countDown(); executor.shutdownNow(); } + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index b190038a9..cdd56136a 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -35,7 +35,7 @@ class MemberServiceTest { @MockBean private TenantRepository tenantRepository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private MemberService service; private IamMembership member; private IamUser user; @@ -106,8 +106,7 @@ class MemberServiceTest { assertEquals("DISABLED", result.status()); verify(repository).save(member); verify(repository).saveUser(user); - verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED"); - verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED"); + verify(securityVersions).subjectChanged(40L); } @Test void shouldHandleCase05() { @@ -156,8 +155,7 @@ class MemberServiceTest { verify(auth).create(eq("internal-token"), account.capture()); long authUserId = account.getValue().accountId(); verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L))); - verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(authUserId); } @Test void shouldHandleCase08() { diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 339dc050c..488e8bb78 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -44,7 +44,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantReviewMapper reviews; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @MockBean private CustomerCodeRepository customerCodes; @Resource private MerchantOnboardingService service; @@ -78,10 +78,8 @@ class MerchantOnboardingServiceTest { verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); - verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); - verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(securityVersions).tenantChanged(result.tenantId()); + verify(securityVersions).subjectChanged(9001L); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals("PENDING", result.dimensionInitializationStatus()); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index ede6e98a7..81d89c307 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -30,7 +30,7 @@ class PlatformInternalUserServiceTest { @MockBean private MemberRepository repository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private PlatformInternalUserService service; private IamMembership membership; private IamUser user; @@ -86,8 +86,7 @@ class PlatformInternalUserServiceTest { verify(repository).insert(any(IamMembership.class)); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); long authUserId = captor.getValue().accountId(); - verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE"); - verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(authUserId); } @Test @@ -103,8 +102,7 @@ class PlatformInternalUserServiceTest { service.create(request); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); - verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(10L); verify(auth, never()).create(anyString(), any()); } @@ -116,7 +114,7 @@ class PlatformInternalUserServiceTest { service.create(request); - verifyNoInteractions(securityEvents); + verifyNoInteractions(securityVersions); verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } @@ -150,9 +148,7 @@ class PlatformInternalUserServiceTest { new ChangeInternalUserStatusRequest("DISABLED"), 99L); assertEquals("DISABLED", result.status()); - verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED"); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index f9e96fda5..359125dc4 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -33,7 +33,7 @@ class PlatformUserManagementServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private PlatformUserManagementService service; private IamRole platformRole; private IamMembership membership; @@ -107,8 +107,7 @@ class PlatformUserManagementServiceTest { verify(auth).create(eq("internal-token"), account.capture()); long authUserId = account.getValue().accountId(); verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L))); - verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED"); - verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(authUserId); } @Test @@ -159,8 +158,7 @@ class PlatformUserManagementServiceTest { assertEquals(900L, membership.getOverseasWarehouseId()); assertEquals(900L, response.overseasWarehouseId()); verify(auth).revokeSessions(eq("internal-token"), any()); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_USER_AUTHORIZATION_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test @@ -197,8 +195,7 @@ class PlatformUserManagementServiceTest { assertEquals(901L, response.overseasWarehouseId()); verify(auth).revokeSessions(eq("internal-token"), eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_USER_AUTHORIZATION_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test @@ -225,8 +222,7 @@ class PlatformUserManagementServiceTest { assertNull(response.overseasWarehouseId()); verify(auth).revokeSessions(eq("internal-token"), eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", - "PLATFORM_USER_AUTHORIZATION_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test @@ -246,8 +242,7 @@ class PlatformUserManagementServiceTest { UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L); assertEquals("ACTIVE", result.status()); - verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); - verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED"); + verify(securityVersions).subjectChanged(10L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 9854bc23e..694286efb 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -40,7 +40,7 @@ class RbacServiceTest { @MockBean private RbacRepository repository; @MockBean private MemberRepository memberRepository; @MockBean private AuthAccountClient authClient; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private RbacService service; @Test @@ -338,6 +338,8 @@ class RbacServiceTest { verify(authClient, times(2)).revokeSessions(eq("test-internal-token"), captor.capture()); assertEquals(Set.of(501L, 502L), captor.getAllValues().get(0).accountIds()); assertEquals(Set.of(501L, 502L), captor.getAllValues().get(1).accountIds()); + verify(securityVersions, times(2)).subjectChanged(501L); + verify(securityVersions, times(2)).subjectChanged(502L); } @Test @@ -368,6 +370,7 @@ class RbacServiceTest { verify(authClient).revokeSessions(eq("test-internal-token"), eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L, 502L, 503L)))); + for (long id : new long[]{501, 502, 503}) verify(securityVersions).subjectChanged(id); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java deleted file mode 100644 index 9ceef4efc..000000000 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.baiyue.iam.client.SecurityEventBridgeClient; -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.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.util.ReflectionTestUtils; - -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class SecurityEventOutboxRelayTest { - @Mock private JdbcTemplate jdbc; - @Mock private SecurityEventBridgeClient client; - private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - private SecurityEventOutboxRelay relay; - - @BeforeEach - void setUp() { - relay = new SecurityEventOutboxRelay(); - ReflectionTestUtils.setField(relay, "jdbc", jdbc); - ReflectionTestUtils.setField(relay, "client", client); - ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); - ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); - ReflectionTestUtils.setField(relay, "maxAttempts", 12); - } - - @Test - void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception { - EventEnvelope envelope = envelope(RegionCode.OVERSEAS); - when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); - when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) - .thenReturn(objectMapper.writeValueAsString(envelope)); - when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( - new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z"))); - - relay.deliver(envelope.eventId()); - - ArgumentCaptor sent = ArgumentCaptor.forClass(EventEnvelope.class); - verify(client).receive(eq("internal-token"), sent.capture()); - assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN); - assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS); - verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString()); - } - - @Test - void nonOverseasTargetNeverReachesTheBridge() throws Exception { - EventEnvelope envelope = envelope(RegionCode.CN); - when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); - when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) - .thenReturn(objectMapper.writeValueAsString(envelope)); - when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0); - - relay.deliver(envelope.eventId()); - - verify(client, never()).receive(anyString(), any(EventEnvelope.class)); - verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(), - eq(envelope.eventId()), anyString()); - } - - private EventEnvelope envelope(RegionCode target) { - var value = new IamSecurityEventsV1.PermissionVersionChanged( - 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"), - java.util.Set.of("PLATFORM_ADMIN"), - java.util.Set.of("platform:overseas-warehouse:last-mile-order:list")); - var payload = objectMapper.valueToTree(value); - return new EventEnvelope("00000000-0000-4000-8000-000000000081", - EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, - Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0", - "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", - null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java deleted file mode 100644 index b7e7f89c9..000000000 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.baiyue.iam.service; - -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.iam.mapper.AuthorizationQueryMapper; -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.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.util.ReflectionTestUtils; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class SecurityEventOutboxServiceTest { - @Mock JdbcTemplate jdbc; - @Mock AuthorizationQueryMapper authorizationQueries; - private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - private SecurityEventOutboxService service; - - @BeforeEach - void setUp() { - service = new SecurityEventOutboxService(); - ReflectionTestUtils.setField(service, "jdbc", jdbc); - ReflectionTestUtils.setField(service, "objectMapper", objectMapper); - ReflectionTestUtils.setField(service, "authorizationQueries", authorizationQueries); - } - - @Test - void userStatusWritesAValidEnvelopeWithCorrelationId() throws Exception { - when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(1L); - - service.userStatus(101L, 0L, "PLATFORM", "DISABLED"); - - ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); - EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); - assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); - } - - @ParameterizedTest - @CsvSource({ - "ACTIVE,ACTIVE", - "PROFILE_REQUIRED,DISABLED", - "PENDING,DISABLED", - "REJECTED,DISABLED", - "DISABLED,DISABLED", - "ARCHIVED,DISABLED" - }) - void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus, - String expectedSecurityStatus) throws Exception { - when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L); - - service.tenantStatus(10001L, businessStatus); - - ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); - EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); - IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue( - envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); - assertThat(payload.status()).isEqualTo(expectedSecurityStatus); - assertThat(envelope.aggregateVersion()).isEqualTo(3L); - } - - @Test - void permissionEventCarriesOnlyTheOverseasAuthorizationSnapshot() throws Exception { - when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(7L); - when(authorizationQueries.findRolesByIdentity(101L, 0L, "ADMIN")) - .thenReturn(List.of("PLATFORM_ADMIN")); - when(authorizationQueries.findPermissionsByIdentity(101L, 0L, "ADMIN")) - .thenReturn(List.of("platform:overseas-warehouse:orders:view", "platform:iam:users:view", - "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view")); - - service.permissionChanged(101L, 0L, "ADMIN", "ROLE_CHANGED"); - - ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " - + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); - EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); - IamSecurityEventsV1.PermissionVersionChanged payload = objectMapper.treeToValue( - envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); - assertThat(payload.roles()).containsExactly("PLATFORM_ADMIN"); - assertThat(payload.authorities()).containsExactlyInAnyOrder( - "platform:overseas-warehouse:orders:view", - "merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view"); - assertThat(payload.authorities()).doesNotContain("platform:iam:users:view"); - } -} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java new file mode 100644 index 000000000..40df8cceb --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityVersionTransactionTest.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.service; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +class SecurityVersionTransactionTest { + @Test void concurrentVersionsAreDurableAndRollbackWithMutation() throws Exception { + var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + var jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT PRIMARY KEY,permission_version BIGINT)"); + jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT PRIMARY KEY,status_version BIGINT)"); + var manager = new DataSourceTransactionManager(ds); + var target = new SecurityVersionService(); ReflectionTestUtils.setField(target, "jdbc", jdbc); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + var service = (SecurityVersionService) proxy.getProxy(); + assertThatThrownBy(() -> service.subjectChanged(42)).isInstanceOf(org.springframework.transaction.IllegalTransactionStateException.class); + new TransactionTemplate(manager).executeWithoutResult(tx -> { service.subjectChanged(42); service.tenantChanged(7); }); + var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(4); + try { + var futures = new ArrayList>(); + for (int i=0; i<8; i++) futures.add(executor.submit(() -> { + try { if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("Start barrier timed out"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); } + new TransactionTemplate(manager).executeWithoutResult(tx -> service.subjectChanged(42)); + })); + start.countDown(); for (var future : futures) future.get(10, TimeUnit.SECONDS); + } finally { executor.shutdownNow(); } + new TransactionTemplate(manager).executeWithoutResult(tx -> { + service.subjectChanged(42); service.tenantChanged(7); tx.setRollbackOnly(); + }); + assertThat(jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=42", Long.class)).isEqualTo(9); + assertThat(jdbc.queryForObject("SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=7", Long.class)).isEqualTo(1); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index bb874cce6..f52624c7c 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -44,7 +44,7 @@ class TenantAdministratorProvisioningServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @@ -104,9 +104,7 @@ class TenantAdministratorProvisioningServiceTest { verify(members).insertUser(any(IamUser.class)); verify(members).insert(any(IamMembership.class)); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); - verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(account.getValue().accountId()); } @Test @@ -141,7 +139,7 @@ class TenantAdministratorProvisioningServiceTest { assertEquals(null, result.temporaryPassword()); verify(auth, never()).create(anyString(), any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); - verifyNoInteractions(securityEvents); + verifyNoInteractions(securityVersions); } @Test @@ -160,8 +158,7 @@ class TenantAdministratorProvisioningServiceTest { assertFalse(result.created()); verify(auth, never()).create(anyString(), any()); verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L)); - verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", - "MEMBER_ROLES_CHANGED"); + verify(securityVersions).subjectChanged(user.getAuthUserId()); } @Test @@ -199,9 +196,7 @@ class TenantAdministratorProvisioningServiceTest { verify(members, never()).insertUser(any()); verify(members, never()).insert(any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); - verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(securityVersions).subjectChanged(user.getAuthUserId()); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index aa1f23fd3..eceb1b97b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -30,7 +30,7 @@ class TenantCustomerExportTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private TenantAdministratorProvisioningService administratorProvisioning; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @Resource private TenantService service; @Test void returnsUnmaskedCustomerRowsAndAdvancingCursor() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 28eb7d4de..9288fe3de 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -57,7 +57,7 @@ class TenantServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient authClient; - @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityVersionService securityVersions; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @SpyBean private TenantAdministratorProvisioningService provisioning; @@ -196,13 +196,13 @@ class TenantServiceTest { when(authClient.revokeSessions("internal-token", request)).thenReturn(null); assertThrows(BizException.class, () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"))); - verify(securityEvents, never()).tenantStatus(org.mockito.ArgumentMatchers.anyLong(), any()); + verify(securityVersions, never()).tenantChanged(org.mockito.ArgumentMatchers.anyLong()); when(authClient.revokeSessions("internal-token", request)) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(null)); assertEquals("DISABLED", service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")).status()); - verify(securityEvents).tenantStatus(10001L, "DISABLED"); + verify(securityVersions).tenantChanged(10001L); } @Test @@ -357,7 +357,7 @@ class TenantServiceTest { verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); - verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); + verify(securityVersions).tenantChanged(created.tenant().id()); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @@ -474,7 +474,7 @@ class TenantServiceTest { assertEquals("ACTIVE", tenant.getStatus()); verify(repository).saveReview(review); verify(repository).saveTenant(tenant); - verify(securityEvents).tenantStatus(10001L, "ACTIVE"); + verify(securityVersions).tenantChanged(10001L); } @Test diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index e880358b8..8347661ef 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -12,7 +12,7 @@ - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` - 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md` -- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` +- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/test/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` ## 修改约束与分期 diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index f6d42fe07..7916af7b8 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -3,7 +3,7 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: integration-service servlet: @@ -16,20 +16,20 @@ spring: - com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} baiyue: security: diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index ac68700b8..ba6124ce2 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -9,6 +9,8 @@ ## 快速索引 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/lastleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许卸柜、派送、暂扣、资金指令与费用事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + 本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。 @@ -26,7 +28,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java` | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java` | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | -| IAM 海外安全投影与实时校验 | `security/controller/LastlegSecurityController.java` | +| 统一会话校验 | 复用 `../common-security/` Redis会话与Auth权威状态校验;Lastleg不维护授权投影 | | 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` | @@ -69,7 +71,7 @@ - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 - 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 -- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 ## 数据库表索引与注释约束 @@ -83,7 +85,6 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | -| 海外安全投影 | `ll_security_event_inbox`、`ll_security_projection` | IAM 安全事件可靠收件箱与 Lastleg 本地授权投影;只允许内部服务入口访问,不作为 loading、shipping、inbound 等业务模型 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -99,9 +100,7 @@ - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 -- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 -- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 -- 开发/测试空库基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;AUTHORIZATION 必须携带当前 `PLATFORM_ADMIN` 角色及按 IAM 跨区前缀筛选的完整权限快照,统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,后续 IAM version>=1 事件单调覆盖。不得扩大为其他历史账号运行时补齐或缺失投影默认放行。 +- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 - 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 @@ -139,7 +138,6 @@ - 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 - 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 -- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 7ea635ea1..a02edfb28 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -31,7 +31,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | -| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | +| 统一认证与撤销 | 复用 common-security 的Redis会话及Auth/IAM权威指纹校验,故障拒绝访问;Lastleg不保存独立授权投影 | | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java new file mode 100644 index 000000000..bf1ab9909 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/lastleg/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("ll_event_outbox", "id", "status", Set.of(EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 2fc682315..9d6dc5565 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasDeliveryCompletedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegOrder; @@ -43,7 +42,7 @@ public class DeliveryCompletedOutboxService { // acknowledgement event. Customer/local overseas orders can still carry // a local sourceInboundId, but deliberately have no domestic sourceOrderId. if (StringUtils.hasText(order.getSourceRegion()) - && !RegionCode.CN.name().equalsIgnoreCase(order.getSourceRegion())) continue; + && !"FIRSTLEG".equals(order.getSourceRegion())) continue; // Relabel and warehouse-transfer deliveries are created and completed wholly // in the overseas last-leg domain. They do not have a domestic source order // to acknowledge, so publishing the cross-region event would make receipt @@ -73,7 +72,7 @@ public class DeliveryCompletedOutboxService { order.getFbaWarehouseCode(), completedAt); JsonNode node = objectMapper.valueToTree(payload); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), - EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, RegionCode.OVERSEAS, RegionCode.CN, + EventTypes.OVERSEAS_DELIVERY_COMPLETED, 1, completedAt, order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), aggregateVersion, 1L, EventIdentifiers.newUuid(), eventKey, EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java index d37fab88b..113830613 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -24,33 +24,41 @@ public class OverseasFeeOutboxRepository { .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) .orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit)); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") - .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) - .or(processing -> - processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) - .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") - .set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker) - .set(LastlegEventOutbox::getLockedAt, now)) == 1; + .eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) + .set(LastlegEventOutbox::getStatus, "PROCESSING") + .set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1; + } + + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker) + .le(LastlegEventOutbox::getLockedAt, cutoff) + .set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING") + .set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); } public LastlegEventOutbox claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper().eq(LastlegEventOutbox::getId, id) .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { - mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) + public int published(long id, String worker, LocalDateTime now) { + return mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null)); } - public void dead(long id, String worker, int failures, String error) { - mapper.update(null, claimed(id, worker, "DEAD") + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimed(id, worker, "DEAD") .set(LastlegEventOutbox::getRetryCount, failures) .set(LastlegEventOutbox::getNextRetryAt, null) .set(LastlegEventOutbox::getLastError, truncate(error))); } - public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) { - mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) + public int retry(long id, String worker, int retryCount, LocalDateTime next, String error) { + return mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) .set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error))); } private LambdaUpdateWrapper claimed(long id, String worker, String status) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java index 8ab22ae04..045e75741 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -1,5 +1,11 @@ package com.baiyue.lastleg.financefee.service; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; @@ -18,10 +24,12 @@ import java.time.ZoneOffset; import java.util.UUID; @Component -public class OverseasFeeOutboxRelay { +public class OverseasFeeOutboxRelay implements EventDeliveryProducer { @Resource private OverseasFeeOutboxRepository repository; @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token:}") private String token; @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; @@ -35,35 +43,63 @@ public class OverseasFeeOutboxRelay { public void relay() { if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); - LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); - for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(leaseSeconds))) { + LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC); + for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(ZoneOffset.UTC); + if ("PROCESSING".equals(candidate.getStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. + } String worker = UUID.randomUUID().toString(); - now = LocalDateTime.now(ZoneOffset.UTC); - - if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(leaseSeconds))) continue; + String type = Stream.FEE.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.FEE.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FEE.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); if (row == null) continue; int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); if (policy.exhausted(failures)) { - repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)"); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(ZoneOffset.UTC), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); continue; } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; boolean requestStarted = false; try { - EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); requestStarted = true; - var response = client.receive(token, envelope); + var response = client.receive(token, event); EventDeliveryFailure.requireSuccessful(response); - response.data().requireMatches(envelope); - repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC)); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); } catch (Exception exception) { - String error = EventDeliveryFailure.diagnostic(exception, token); - boolean permanent = EventDeliveryFailure.isPermanent(exception) + permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - int retries = failures + 1; - if (permanent || policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, error); - else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), error); + diagnostic = EventDeliveryFailure.diagnostic(exception, token); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(ZoneOffset.UTC); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, retries, next, error); + return repository.published(row.getId(), worker, finished); + }); } } + + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FEE, LocalDateTime.now(ZoneOffset.UTC)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index 349dab9e1..98296a317 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; @@ -37,7 +36,7 @@ public class OverseasFeeOutboxService { + source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action; if (mapper.findByEventKey(eventKey) != null) return; EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()), source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index 5749d7013..5b5e9e4f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -1,5 +1,11 @@ package com.baiyue.lastleg.inboundplan.pda.service; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.web.EventDeliveryFailure; import com.fasterxml.jackson.core.JsonProcessingException; @@ -22,10 +28,12 @@ import java.util.UUID; /** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */ @Component @Slf4j -public class InboundUnloadCompletedOutboxRelay { +public class InboundUnloadCompletedOutboxRelay implements EventDeliveryProducer { @Resource private UnloadCompletedOutboxRepository repository; @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @@ -38,44 +46,63 @@ public class InboundUnloadCompletedOutboxRelay { public void relay() { if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); - LocalDateTime now = LocalDateTime.now(); - LocalDateTime expiredLeaseBefore = now.minusSeconds(leaseSeconds); - for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) { - String workerId = UUID.randomUUID().toString(); - now = LocalDateTime.now(); - expiredLeaseBefore = now.minusSeconds(leaseSeconds); - - if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue; - LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId); + LocalDateTime scan = LocalDateTime.now(); + for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(); + if ("PROCESSING".equals(candidate.getStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. + } + String worker = UUID.randomUUID().toString(); + String type = Stream.LASTLEG.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.LASTLEG.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.LASTLEG.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; + LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); if (row == null) continue; int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); if (policy.exhausted(failures)) { - repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); continue; } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; boolean requestStarted = false; try { - EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); - EventEnvelopeValidator.requireValid(event); + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); requestStarted = true; var response = client.receive(internalToken, event); - requireAcknowledgement(event, response); - repository.published(row.getId(), workerId, LocalDateTime.now()); + EventDeliveryFailure.requireSuccessful(response); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); } catch (Exception exception) { - String error = EventDeliveryFailure.diagnostic(exception, internalToken); - boolean permanent = EventDeliveryFailure.isPermanent(exception) + permanent = EventDeliveryFailure.isPermanent(exception) || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); - if (permanent || policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); - else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), - error); - log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), error); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, next, error); + return repository.published(row.getId(), worker, finished); + }); } } - private void requireAcknowledgement(EventEnvelope event, - com.baiyue.common.core.api.ApiResponse response) { - EventDeliveryFailure.requireSuccessful(response); - response.data().requireMatches(event); - } + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.LASTLEG, LocalDateTime.now()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index 60de90bae..db7c5f342 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; @@ -78,7 +77,7 @@ public class InboundUnloadCompletedOutboxService { JsonNode payloadNode = objectMapper.valueToTree(payload); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, - 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING", + 1, Instant.now(), "0", "CONTAINER_BOOKING", sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index bbce4b4b1..9858946d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -32,21 +32,25 @@ public class UnloadCompletedOutboxRepository { .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() - .eq(LastlegEventOutbox::getId, id) - .in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") - .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) - .or().le(LastlegEventOutbox::getNextRetryAt, now)) - .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") - .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) - .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") + .eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) .set(LastlegEventOutbox::getStatus, "PROCESSING") - .set(LastlegEventOutbox::getLockedBy, worker) - .set(LastlegEventOutbox::getLockedAt, now)) == 1; + .set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1; } + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker) + .le(LastlegEventOutbox::getLockedAt, cutoff) + .set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING") + .set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } public LastlegEventOutbox claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() .eq(LastlegEventOutbox::getId, id) @@ -55,8 +59,8 @@ public class UnloadCompletedOutboxRepository { .eq(LastlegEventOutbox::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { - mapper.update(null, claimedBase(id, worker) + public int published(long id, String worker, LocalDateTime now) { + return mapper.update(null, claimedBase(id, worker) .set(LastlegEventOutbox::getStatus, "PUBLISHED") .set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null) @@ -65,16 +69,16 @@ public class UnloadCompletedOutboxRepository { .set(LastlegEventOutbox::getLastError, null)); } - public void dead(long id, String worker, int failures, String error) { - mapper.update(null, claimedBase(id, worker) + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimedBase(id, worker) .set(LastlegEventOutbox::getStatus, "DEAD") .set(LastlegEventOutbox::getRetryCount, failures) .set(LastlegEventOutbox::getNextRetryAt, null) .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) .set(LastlegEventOutbox::getLastError, truncate(error))); } - public void retry(long id, String worker, LocalDateTime retryAt, String error) { - mapper.update(null, claimedBase(id, worker) + public int retry(long id, String worker, LocalDateTime retryAt, String error) { + return mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1") .set(LastlegEventOutbox::getStatus, "PENDING") .set(LastlegEventOutbox::getNextRetryAt, retryAt) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java deleted file mode 100644 index 6b3cb5093..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.lastleg.security.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.lastleg.security.InternalServiceTokenGuard; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; -import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService; -import com.baiyue.lastleg.security.service.LastlegSecurityValidationService; -import io.swagger.v3.oas.annotations.Hidden; -import jakarta.validation.Valid; -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.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.time.Instant; - -@Hidden -@RestController -@RequestMapping("/internal/lastleg/security/v1") -public class LastlegSecurityController { - private final InternalServiceTokenGuard tokenGuard; - private final LastlegSecurityProjectionService projections; - private final LastlegSecurityValidationService validations; - - public LastlegSecurityController(InternalServiceTokenGuard tokenGuard, - LastlegSecurityProjectionService projections, - LastlegSecurityValidationService validations) { - this.tokenGuard = tokenGuard; - this.projections = projections; - this.validations = validations; - } - - @PostMapping("/events") - public ApiResponse receive( - @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @RequestBody EventEnvelope envelope) { - tokenGuard.verify(token); - var result = projections.consume(envelope); - return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now())); - } - - @PostMapping("/validate") - public ApiResponse validate( - @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @Valid @RequestBody ValidationRequest request) { - tokenGuard.verify(token); - return ApiResponse.success(validations.validate(request)); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java deleted file mode 100644 index b7a7e72ae..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.baiyue.lastleg.security.dto; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; - -import java.time.Instant; -import java.util.Set; - -public final class LastlegSecurityDtos { - private LastlegSecurityDtos() { } - - public record Acknowledgement(String eventId, String result, Instant receivedAt) { } - - public record ValidationRequest(@NotNull @Min(1) Long userId, - @NotNull @Min(0) Long tenantId, - @NotNull @Min(0) Long permissionVersion) { } - - public record ValidationResponse(boolean valid, FailureReason failureReason, - Set roles, Set authorities) { - public ValidationResponse { - roles = roles == null ? Set.of() : Set.copyOf(roles); - authorities = authorities == null ? Set.of() : Set.copyOf(authorities); - } - public static ValidationResponse accepted(Set roles, Set authorities) { - return new ValidationResponse(true, null, roles, authorities); - } - public static ValidationResponse rejected(FailureReason reason) { - return new ValidationResponse(false, reason, Set.of(), Set.of()); - } - } - - public enum FailureReason { - SECURITY_PROJECTION_MISSING, - SECURITY_PROJECTION_NOT_READY, - USER_DISABLED, - TENANT_DISABLED, - PERMISSION_VERSION_STALE - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java deleted file mode 100644 index 5e888487b..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ -@Component -@Slf4j -public class LastlegSecurityProjectionRedisMirror { - private final StringRedisTemplate redis; - private final ObjectMapper objectMapper; - @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") - private String keyPrefix; - - public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { - this.redis = redis; - this.objectMapper = objectMapper; - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) { - var value = event.projection(); - try { - redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), - objectMapper.writeValueAsString(value)); - } catch (Exception exception) { - log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}", - value.aggregateType(), value.aggregateId()); - } - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java deleted file mode 100644 index 298d506c6..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; - -@Service -public class LastlegSecurityProjectionService { - private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, - EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); - private final JdbcTemplate jdbc; - private final ObjectMapper objectMapper; - private final ApplicationEventPublisher events; - - public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, - ApplicationEventPublisher events) { - this.jdbc = jdbc; - this.objectMapper = objectMapper; - this.events = events; - } - - @Transactional - public ConsumeResult consume(EventEnvelope envelope) { - envelope.requireValid(); - requireSecurityEnvelope(envelope); - String payloadHash = validatedStorageHash(envelope); - String payloadJson = envelope.payload().toString(); - try { - jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " - + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), - payloadHash, payloadJson); - } catch (DuplicateKeyException duplicate) { - InboxRow existing = jdbc.queryForObject( - "SELECT event_type,payload_hash,status FROM ll_security_event_inbox " - + "WHERE event_id=? FOR UPDATE", - (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), - envelope.eventId()); - if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) - || existing.payloadHash() == null - || !existing.payloadHash().equalsIgnoreCase(payloadHash)) { - throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); - } - if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); - } - - ProjectionInput input = input(envelope, payloadHash); - ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); - SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide( - current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), - input.aggregateVersion(), input.payloadHash()); - String result = decision.name(); - if (decision == SecurityProjectionMonotonicity.Decision.APPLY) { - upsert(input); - result = "APPLIED"; - events.publishEvent(new ProjectionChanged(input)); - } - jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " - + "WHERE event_id=?", result, envelope.eventId()); - return new ConsumeResult(result, input); - } - - private void requireSecurityEnvelope(EventEnvelope envelope) { - if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION - || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { - throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件"); - } - } - - static String validatedStorageHash(EventEnvelope envelope) { - if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), - envelope.payloadHash())) { - throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致"); - } - // 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。 - return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT); - } - - private ProjectionInput input(EventEnvelope envelope, String payloadHash) { - try { - return switch (envelope.eventType()) { - case EventTypes.IAM_USER_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), - IamSecurityEventsV1.UserStatusChanged.class); - requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), value.permissionVersion(), - envelope.aggregateVersion()); - requireStatus(value.status()); - yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), - value.status(), value.permissionVersion(), envelope.aggregateVersion(), - payloadHash, envelope.eventId(), value.changedAt(), Set.of(), Set.of()); - } - case EventTypes.IAM_TENANT_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), - IamSecurityEventsV1.TenantStatusChanged.class); - requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), - envelope.aggregateVersion()); - requireStatus(value.status()); - yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), - value.status(), null, envelope.aggregateVersion(), payloadHash, - envelope.eventId(), value.changedAt(), Set.of(), Set.of()); - } - case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), - IamSecurityEventsV1.PermissionVersionChanged.class); - requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); - requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), - envelope.aggregateVersion()); - requireAuthorizationSnapshot(value.roles(), value.authorities()); - yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), - null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash, - envelope.eventId(), value.changedAt(), value.roles(), value.authorities()); - } - default -> throw new IllegalArgumentException("unsupported type"); - }; - } catch (BizException exception) { - throw exception; - } catch (Exception exception) { - throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效"); - } - } - - private static void requireIdentity(EventEnvelope envelope, String aggregateType, - long aggregateId, long tenantId) { - if (!aggregateType.equals(envelope.aggregateType()) - || !String.valueOf(aggregateId).equals(envelope.aggregateId()) - || !String.valueOf(tenantId).equals(envelope.tenantId())) { - throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致"); - } - } - - private static void requirePayload(String requiredText, Instant changedAt, - long payloadVersion, long aggregateVersion) { - if (requiredText == null || requiredText.isBlank() || changedAt == null - || payloadVersion != aggregateVersion) { - throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); - } - } - - private static void requireStatus(String status) { - if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) { - throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED"); - } - } - - private static void requireAuthorizationSnapshot(Set roles, Set authorities) { - if (roles == null || authorities == null - || roles.stream().anyMatch(value -> value == null || value.isBlank()) - || authorities.stream().anyMatch(value -> value == null || value.isBlank())) { - throw new BizException(ResultCode.BAD_REQUEST, "IAM 海外授权快照无效"); - } - } - - private ProjectionRow findForUpdate(String type, String id) { - List rows = jdbc.query( - "SELECT aggregate_version,payload_hash FROM ll_security_projection " - + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", - (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); - return rows.isEmpty() ? null : rows.get(0); - } - - private void upsert(ProjectionInput value) { - final String rolesJson; - final String authoritiesJson; - try { - rolesJson = objectMapper.writeValueAsString(value.roles()); - authoritiesJson = objectMapper.writeValueAsString(value.authorities()); - } catch (Exception exception) { - throw new IllegalStateException("海外授权快照序列化失败", exception); - } - jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," - + "permission_version,roles_json,authorities_json,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " - + "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," - + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," - + "roles_json=VALUES(roles_json),authorities_json=VALUES(authorities_json)," - + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," - + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", - value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), - value.permissionVersion(), rolesJson, authoritiesJson, value.aggregateVersion(), - value.payloadHash(), value.eventId(), value.changedAt()); - } - - private record InboxRow(String eventType, String payloadHash, String status) { } - private record ProjectionRow(long aggregateVersion, String payloadHash) { } - public record ConsumeResult(String result, ProjectionInput projection) { } - public record ProjectionChanged(ProjectionInput projection) { } - public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status, - Long permissionVersion, long aggregateVersion, String payloadHash, - String eventId, Instant changedAt, Set roles, - Set authorities) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java deleted file mode 100644 index 1d8224b3f..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Set; - -@Service -public class LastlegSecurityValidationService { - private final JdbcTemplate jdbc; - private final ObjectMapper objectMapper; - - public LastlegSecurityValidationService(JdbcTemplate jdbc, ObjectMapper objectMapper) { - this.jdbc = jdbc; - this.objectMapper = objectMapper; - } - - public ValidationResponse validate(ValidationRequest request) { - String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId()); - if (userStatus == null) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - if (!"ACTIVE".equals(userStatus)) { - return ValidationResponse.rejected(FailureReason.USER_DISABLED); - } - if (request.tenantId() > 0) { - String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId()); - if (tenantStatus == null) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - if (!"ACTIVE".equals(tenantStatus)) { - return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); - } - } - List projectedVersions = jdbc.query( - "SELECT permission_version FROM ll_security_projection " - + "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?", - (rs, row) -> rs.getLong(1), - String.valueOf(request.userId()), request.tenantId()); - if (projectedVersions.isEmpty()) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - long projectedVersion = projectedVersions.get(0); - if (request.permissionVersion() < projectedVersion) { - return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); - } - if (request.permissionVersion() > projectedVersion) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY); - } - List snapshots = jdbc.query( - "SELECT CONCAT(roles_json, CHAR(10), authorities_json) FROM ll_security_projection " - + "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?", - (rs, row) -> rs.getString(1), String.valueOf(request.userId()), request.tenantId()); - if (snapshots.isEmpty() || snapshots.get(0) == null || !snapshots.get(0).contains("\n")) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - try { - String[] values = snapshots.get(0).split("\n", 2); - return ValidationResponse.accepted(readSet(values[0]), readSet(values[1])); - } catch (Exception exception) { - return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); - } - } - - private String status(String type, String id, long tenantId) { - List values = jdbc.query("SELECT security_status FROM ll_security_projection " - + "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?", - (rs, row) -> rs.getString(1), type, id, tenantId); - return values.isEmpty() ? null : values.get(0); - } - - private Set readSet(String json) throws Exception { - if (json == null || json.isBlank()) return Set.of(); - return objectMapper.readValue(json, new TypeReference>() { }); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java deleted file mode 100644 index 4085bc4de..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; - -import java.util.Objects; - -final class SecurityProjectionMonotonicity { - private SecurityProjectionMonotonicity() { } - - static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { - if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; - if (incomingVersion < currentVersion) return Decision.STALE; - if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION; - throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突"); - } - - enum Decision { APPLY, STALE, DUPLICATE_VERSION } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java index 52bf9194a..23ec86465 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; @@ -41,8 +40,7 @@ public class InboundOrderDetainedOutboxService { String causationId = EventIdentifiers.isSupported(order.getSourceEventId()) ? order.getSourceEventId() : null; EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), - EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt, - RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER", + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt, order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(), eventKey, causationId, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 12f79d4c1..1b5471092 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -56,8 +56,6 @@ public class LoadingCompletedConsumer { if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3)) throw new EventProtocolException("不支持的尾程交接事件"); - if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) - throw new EventProtocolException("尾程交接事件区域无效"); if (!"LOADING_PLAN".equals(envelope.aggregateType())) throw new EventProtocolException("尾程交接事件聚合类型无效"); if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), @@ -283,7 +281,7 @@ public class LoadingCompletedConsumer { FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource, String containerNo, String sourceContainerBookingId, OwnerSnapshot owner) { String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); - LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); + LastlegOrder existing=orderMapper.findSource(event.tenantId(),"FIRSTLEG",source.sourceInboundId()); if(existing!=null) { if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId()); return; @@ -302,7 +300,7 @@ public class LoadingCompletedConsumer { } order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); order.setDestinationType(destinationType); - order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); + order.setInboundCode(source.inboundCode()); order.setSourceRegion("FIRSTLEG"); order.setLxCode("ONE_CLICK_DROPSHIP".equals(orderType) ? source.lxCode() : null); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); @@ -331,7 +329,7 @@ public class LoadingCompletedConsumer { try { orderMapper.insert(order); } catch (DuplicateKeyException exception) { - LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(), + LastlegOrder raced = orderMapper.findSource(event.tenantId(), "FIRSTLEG", source.sourceInboundId()); if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return; throw exception; @@ -399,7 +397,7 @@ public class LoadingCompletedConsumer { String orderHash) { LastlegInboundOrder snapshot = new LastlegInboundOrder(); snapshot.setId(order.getId()); - snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion(event.sourceRegion().name()); + snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion("FIRSTLEG"); snapshot.setSourceInboundId(source.sourceInboundId()); snapshot.setRootInboundId(source.rootInboundId()); snapshot.setSourceOrderId(source.sourceOrderId()); snapshot.setSourceOrderNo(source.sourceOrderNo()); snapshot.setRootOrderId(source.rootOrderId()); snapshot.setInboundCode(source.inboundCode()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index 0e7b69132..1801f6c13 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -8,7 +8,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventInbox; import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; @@ -104,8 +103,7 @@ public class ContainerShippingNodeConsumer { throw new EventProtocolException("payloadHash 与船节点事件负载不一致"); } if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) - || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN - || envelope.targetRegion() != RegionCode.OVERSEAS + || envelope.eventVersion() != 1 || !"CONTAINER_BOOKING".equals(envelope.aggregateType()) || envelope.handoverVersion() == null || envelope.handoverVersion() != envelope.aggregateVersion() + 1) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java index ca8858b46..4d6f51e58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java @@ -6,7 +6,6 @@ import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandCompletedV1; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -54,9 +53,8 @@ public class WalletCommandCompletedConsumer { try { EventEnvelopeValidator.requireValid(envelope); } catch (RuntimeException exception) { throw bad("资金结果事件信封无效"); } if (!EventTypes.WALLET_COMMAND_COMPLETED.equals(envelope.eventType()) - || envelope.eventVersion() != WalletCommandCompletedV1.VERSION - || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { - throw bad("资金结果事件类型、版本或区域无效"); + || envelope.eventVersion() != WalletCommandCompletedV1.VERSION) { + throw bad("资金结果事件类型或版本无效"); } if (!EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(envelope.payload())).equals(envelope.payloadHash())) { throw bad("资金结果事件载荷哈希无效"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java index 5225583d9..bf5320cdb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java @@ -6,7 +6,6 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandRequestedV1; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; @@ -36,7 +35,7 @@ public class WalletCommandOutboxService { businessNo, amount, remark, idempotencyKey, Instant.now()); JsonNode payload = objectMapper.valueToTree(value); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED, - WalletCommandRequestedV1.VERSION, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + WalletCommandRequestedV1.VERSION, Instant.now(), String.valueOf(customerId), businessType, businessNo, aggregateVersion, null, EventIdentifiers.newUuid(), eventKey, null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 6164da56a..21524c7a4 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -8,35 +8,35 @@ spring: max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB} sql: init: - mode: always + mode: never encoding: UTF-8 schema-locations: classpath:db/lastleg.sql data: redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} application: name: lastleg-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${LASTLEG_DB_USERNAME:root} - password: ${LASTLEG_DB_PASSWORD:123456} + url: ${LASTLEG_DB_URL} + username: ${LASTLEG_DB_USERNAME} + password: ${LASTLEG_DB_PASSWORD} management.endpoints.web.exposure.include: health,info,prometheus lastleg: @@ -76,21 +76,16 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} + url: ${FILE_SERVICE_URL:http://file:9206} domestic-file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} + url: ${FILE_SERVICE_URL:http://file:9206} firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} jwt-mode: jwks session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index a558dba68..d60edd834 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2327,181 +2327,6 @@ CREATE TABLE IF NOT EXISTS ll_customer_visit ( KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) ) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; -CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( - event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', - event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', - payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', - payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', - status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', - result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', - received_at DATETIME(3) NOT NULL COMMENT '接收时间', - processed_at DATETIME(3) NULL COMMENT '处理完成时间', - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; - -CREATE TABLE IF NOT EXISTS ll_security_projection ( - aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', - aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', - tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', - security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', - permission_version BIGINT NULL COMMENT '当前单调权限版本', - roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', - authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照', - aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', - payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', - last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', - changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', - PRIMARY KEY (aggregate_type,aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id,permission_version) -) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; - --- 收敛尚未具备授权快照字段的开发/测试库,CREATE TABLE IF NOT EXISTS 不会补充已存在表的字段。 -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1', - 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1', - 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL; -UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL; -ALTER TABLE ll_security_projection - MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', - MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照'; - --- 空库基线固定管理员必须携带当前海外授权快照;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 -SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN'); -SET @platform_admin_overseas_authorities = JSON_ARRAY( - 'platform:overseas-warehouse:warehouse-transfer:approve', - 'platform:finance:bill:adjust', - 'platform:finance:bill:update', - 'platform:finance:bill:void', - 'platform:finance:cost:create', - 'platform:finance:cost:view', - 'platform:finance:customer-account:adjust', - 'platform:finance:customer-account:export', - 'platform:finance:customer-account:list', - 'platform:finance:customer-arap:export', - 'platform:finance:customer-arap:list', - 'platform:finance:customer-statement:export', - 'platform:finance:customer-statement:list', - 'platform:finance:customer-terms:update', - 'platform:finance:customer-terms:view', - 'platform:finance:fee-source:edit', - 'platform:finance:fee-source:review', - 'platform:finance:payment:create', - 'platform:finance:receipt:create', - 'platform:finance:receipt:view', - 'platform:finance:receivable-statistics:export', - 'platform:finance:receivable-statistics:list', - 'platform:finance:recharge:review', - 'platform:finance:reconciliation:adjust', - 'platform:finance:reconciliation:create', - 'platform:finance:reconciliation:issue', - 'platform:finance:reconciliation:review', - 'platform:finance:reconciliation:void', - 'platform:finance:settlement-ap:create', - 'platform:finance:settlement-ap:export', - 'platform:finance:settlement-ap:list', - 'platform:finance:settlement-ar:create', - 'platform:finance:settlement-ar:export', - 'platform:finance:settlement-ar:list', - 'platform:finance:settlement:void', - 'platform:overseas-business:looseboard:create', - 'platform:overseas-business:looseboard:edit', - 'platform:overseas-business:looseboard:list', - 'platform:overseas-business:looseboard:status', - 'platform:overseas-business:purchase:edit', - 'platform:overseas-business:purchase:list', - 'platform:overseas-business:purchase:review', - 'platform:overseas-business:truck:create', - 'platform:overseas-business:truck:edit', - 'platform:overseas-business:truck:list', - 'platform:overseas-business:truck:status', - 'platform:overseas-master-data:exchange-rate:create', - 'platform:overseas-master-data:exchange-rate:edit', - 'platform:overseas-master-data:exchange-rate:export', - 'platform:overseas-master-data:exchange-rate:list', - 'platform:overseas-master-data:exchange-rate:status', - 'platform:overseas-master-data:quote:create', - 'platform:overseas-master-data:quote:edit', - 'platform:overseas-master-data:quote:export', - 'platform:overseas-master-data:quote:list', - 'platform:overseas-master-data:quote:status', - 'platform:overseas-master-data:vehicle:create', - 'platform:overseas-master-data:vehicle:edit', - 'platform:overseas-master-data:vehicle:export', - 'platform:overseas-master-data:vehicle:list', - 'platform:overseas-master-data:vehicle:status', - 'platform:overseas-warehouse:customer-visit:edit', - 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:delivery:documents', - 'platform:overseas-warehouse:delivery:list', - 'platform:overseas-warehouse:delivery:return', - 'platform:overseas-warehouse:delivery:status', - 'platform:overseas-warehouse:dispatch:cancel', - 'platform:overseas-warehouse:dispatch:edit', - 'platform:overseas-warehouse:dispatch:list', - 'platform:overseas-warehouse:dispatch:transfer-delivery', - 'platform:overseas-warehouse:exception-order:create', - 'platform:overseas-warehouse:exception-order:list', - 'platform:overseas-warehouse:exception-order:process', - 'platform:overseas-warehouse:inbound-order:create', - 'platform:overseas-warehouse:inbound-order:edit', - 'platform:overseas-warehouse:inbound-order:fba-appointment', - 'platform:overseas-warehouse:inbound-order:list', - 'platform:overseas-warehouse:inbound-order:smart-dispatch', - 'platform:overseas-warehouse:inventory:adjust', - 'platform:overseas-warehouse:inventory:edit', - 'platform:overseas-warehouse:inventory:export', - 'platform:overseas-warehouse:inventory:list', - 'platform:overseas-warehouse:last-mile-order:edit', - 'platform:overseas-warehouse:last-mile-order:export', - 'platform:overseas-warehouse:last-mile-order:list', - 'platform:overseas-warehouse:location:create', - 'platform:overseas-warehouse:location:delete', - 'platform:overseas-warehouse:location:edit', - 'platform:overseas-warehouse:location:list', - 'platform:overseas-warehouse:outbound:cancel', - 'platform:overseas-warehouse:outbound:create', - 'platform:overseas-warehouse:outbound:list', - 'platform:overseas-warehouse:pallet:create', - 'platform:overseas-warehouse:pallet:delete', - 'platform:overseas-warehouse:pallet:edit', - 'platform:overseas-warehouse:pallet:list', - 'platform:overseas-warehouse:relabel-order:create', - 'platform:overseas-warehouse:relabel-order:list', - 'platform:overseas-warehouse:relabel-order:status', - 'platform:overseas-warehouse:return-cartons:list', - 'platform:overseas-warehouse:return-inventory:list', - 'platform:overseas-warehouse:warehouse:create', - 'platform:overseas-warehouse:warehouse:edit', - 'platform:overseas-warehouse:warehouse:list', - 'platform:overseas-warehouse:warehouse:status' -); -INSERT IGNORE INTO ll_security_projection ( - aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, - payload_hash,last_event_id,changed_at,updated_at -) VALUES - ('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256), - 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), - ('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0, - SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), - 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); - -UPDATE ll_security_projection -SET roles_json=@platform_admin_overseas_roles, - authorities_json=@platform_admin_overseas_authorities, - payload_hash=SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0:current-authorities',256), - updated_at=CURRENT_TIMESTAMP(3) -WHERE aggregate_type='AUTHORIZATION' AND aggregate_id='1000001' AND tenant_id=0 - AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'; - - CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID', command_event_id VARCHAR(64) NOT NULL COMMENT '原海外资金指令事件ID', @@ -2692,3 +2517,38 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE ll_return_order_discrepancy_stmt FROM @ddl; EXECUTE ll_return_order_discrepancy_stmt; DEALLOCATE PREPARE ll_return_order_discrepancy_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='manually_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason')); PREPARE ll_return_order_manual_completed_stmt FROM @ddl; EXECUTE ll_return_order_manual_completed_stmt; DEALLOCATE PREPARE ll_return_order_manual_completed_stmt; + +CREATE TABLE IF NOT EXISTS event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +CREATE TABLE IF NOT EXISTS event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java index 154faaaf0..060d7b1ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java @@ -70,8 +70,7 @@ class DirectEventClientTest { private EventEnvelope event(String type) { var payload = new ObjectMapper().createObjectNode().put("businessNo", "test"); - return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(), - RegionCode.OVERSEAS, RegionCode.CN, "1", "BUSINESS", "1", 1L, null, + return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(), "1", "BUSINESS", "1", 1L, null, "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java new file mode 100644 index 000000000..5734977e9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventOperationsControllerTest { + @Test void credentialFailureCannotReadOrMutateDatabase() { + var jdbc = mock(JdbcTemplate.class); + var transactions = mock(PlatformTransactionManager.class); + for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) { + var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured); + assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class); + } + verifyNoInteractions(jdbc, transactions); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java index 3291ef092..af63f701c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java @@ -8,6 +8,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.UUID; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -16,13 +21,18 @@ class OverseasFeeOutboxRetryTest { var repository = mock(OverseasFeeOutboxRepository.class); var client = mock(DirectEventClient.class); var relay = new OverseasFeeOutboxRelay(); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + ReflectionTestUtils.setField(relay, "journal", new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource))); + when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1); ReflectionTestUtils.setField(relay, "repository", repository); ReflectionTestUtils.setField(relay, "client", client); ReflectionTestUtils.setField(relay, "objectMapper", new ObjectMapper()); var row = new LastlegEventOutbox(); - row.setId(1L); row.setRetryCount(11); row.setPayloadJson("invalid-json"); + row.setId(1L); row.setEventType("BusinessFeeSourceChanged"); row.setRetryCount(11); row.setPayloadJson("invalid-json"); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(1L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(1L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(1L), anyString())).thenReturn(row); relay.relay(); row.setRetryCount(12); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index d542c183b..28ea51299 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; @@ -23,6 +22,11 @@ import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -35,11 +39,21 @@ class InboundUnloadCompletedOutboxRelayTest { private ObjectMapper objectMapper; private InboundUnloadCompletedOutboxRelay relay; + private EventDeliveryJournal journal; + @BeforeEach void setUp() { objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); relay = new InboundUnloadCompletedOutboxRelay(); ReflectionTestUtils.setField(relay, "repository", repository); + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + ReflectionTestUtils.setField(relay, "journal", journal); + lenient().when(repository.published(anyLong(), anyString(), any())).thenReturn(1); + lenient().when(repository.retry(anyLong(), anyString(), any(), anyString())).thenReturn(1); + lenient().when(repository.dead(anyLong(), anyString(), anyInt(), anyString())).thenReturn(1); ReflectionTestUtils.setField(relay, "client", client); ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); @@ -52,7 +66,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED))); @@ -68,7 +82,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenThrow(new IllegalStateException("consumer unavailable")); @@ -84,7 +98,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST, @@ -102,7 +116,7 @@ class InboundUnloadCompletedOutboxRelayTest { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.success(new DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), DeliveryAck.Outcome.APPLIED))) @@ -118,7 +132,7 @@ class InboundUnloadCompletedOutboxRelayTest { LastlegEventOutbox row = row(event()); row.setRetryCount(11); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); relay.relay(); @@ -132,7 +146,7 @@ class InboundUnloadCompletedOutboxRelayTest { LastlegEventOutbox row = row(event()); row.setRetryCount(12); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); - when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claim(eq(9001L), anyString(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); relay.relay(); verifyNoInteractions(client); @@ -140,6 +154,19 @@ class InboundUnloadCompletedOutboxRelayTest { verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); } + @Test + void expiredLeaseIsAuditedAndBackedOffWithoutImmediateHttp() throws Exception { + var row = row(event()); + row.setStatus("PROCESSING"); row.setLockedBy("old"); row.setRetryCount(1); + journal.begin("old", "lastleg", row.getEventType(), "9001", null, "consumer", LocalDateTime.now().minusMinutes(2), () -> true); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.expire(eq(9001L), eq("old"), any(), eq(2), any(), anyString())).thenReturn(1); + relay.relay(); + verify(repository).expire(eq(9001L), eq("old"), any(), eq(2), argThat(next -> next.isAfter(LocalDateTime.now())), anyString()); + verify(repository, never()).claim(anyLong(), anyString(), any()); + verifyNoInteractions(client); + } + private LastlegEventOutbox row(EventEnvelope event) throws Exception { LastlegEventOutbox row = new LastlegEventOutbox(); row.setId(9001L); @@ -153,7 +180,7 @@ class InboundUnloadCompletedOutboxRelayTest { Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); return new EventEnvelope("00000000-0000-4000-8000-000000000009", EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"), - RegionCode.OVERSEAS, RegionCode.CN, "1", "CONTAINER_BOOKING", "1001", 7L, 1L, + "1", "CONTAINER_BOOKING", "1001", 7L, 1L, "trace-12345678", "correlation-12345678", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java index 1ad8c3c5e..973ae5eaf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java @@ -116,7 +116,6 @@ class InboundUnloadCompletedOutboxServiceTest { Instant.parse("2026-08-24T09:30:00Z"), Instant.parse("2026-08-24T08:00:00Z"), null); EventEnvelope envelope = new EventEnvelope("123e4567-e89b-12d3-a456-426614174000", EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-24T09:30:01Z"), - com.baiyue.common.event.RegionCode.OVERSEAS, com.baiyue.common.event.RegionCode.CN, customerId, "CONTAINER_BOOKING", bookingId, 7, 1L, "123e4567-e89b-12d3-a456-426614174001", EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + bookingId, null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java index 5c9b6f049..3cddb32a8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -2,55 +2,93 @@ package com.baiyue.lastleg.repository; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.common.event.EventTypes; -import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; -import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import org.mybatis.spring.SqlSessionTemplate; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; class OutboxLeaseDatabaseTest { - @Test void recoveredLeasesCountFailuresAndOldOwnersCannotChangeNewState() throws Exception { - var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() - + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + @Test void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var jdbc = new JdbcTemplate(dataSource); - jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_type VARCHAR(64),event_version INT,event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); var configuration = new MybatisConfiguration(); configuration.addMapper(LastlegEventOutboxMapper.class); var factory = new MybatisSqlSessionFactoryBean(); - factory.setDataSource(dataSource); - factory.setConfiguration(configuration); - try (var session = factory.getObject().openSession(true)) { - var mapper = session.getMapper(LastlegEventOutboxMapper.class); - var unload = new UnloadCompletedOutboxRepository(); - ReflectionTestUtils.setField(unload, "mapper", mapper); - var fee = new OverseasFeeOutboxRepository(mapper); - LocalDateTime now = LocalDateTime.now(); - for (long id : new long[]{1, 2}) { - jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(?,?,'PENDING',0,?,?)", - id, id == 1 ? EventTypes.OVERSEAS_CONTAINER_UNLOADED : EventTypes.BUSINESS_FEE_SOURCE_CHANGED, - now.minusSeconds(1), now.minusMinutes(2)); - boolean first = id == 1 ? unload.claim(id, "old", now, now.minusSeconds(60)) : fee.claim(id, "old", now, now.minusSeconds(60)); - assertThat(first).isTrue(); - assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isZero(); - jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=?", now.minusSeconds(61), id); - boolean second = id == 1 ? unload.claim(id, "new", now, now.minusSeconds(60)) : fee.claim(id, "new", now, now.minusSeconds(60)); - assertThat(second).isTrue(); - assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isEqualTo(1); - if (id == 1) unload.dead(id, "old", 12, "late failure"); else fee.dead(id, "old", 12, "late failure"); - assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("PROCESSING"); - if (id == 1) unload.dead(id, "new", 12, "exhausted"); else fee.dead(id, "new", 12, "exhausted"); - assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("DEAD"); - boolean third = id == 1 ? unload.claim(id, "third", now, now.minusSeconds(60)) : fee.claim(id, "third", now, now.minusSeconds(60)); - assertThat(third).isFalse(); - } - jdbc.update("UPDATE ll_event_outbox SET status='PENDING',next_retry_at=? WHERE id=2", now.plusMinutes(5)); - assertThat(fee.claim(2L, "early", now, now.minusSeconds(60))).isFalse(); - } + factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); + var repository = new UnloadCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); + assertThat(repository.claim(1L, "new", now)).isFalse(); + var next = now.plusSeconds(10); + assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next, + () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1); + assertThat(repository.claim(1L, "early", now)).isFalse(); + assertThat(journal.begin("new", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", next, + () -> repository.claim(1L, "new", next))).isTrue(); + assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, + () -> repository.dead(1L, "old", 12, "late"))).isFalse(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PROCESSING"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isTrue(); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PUBLISHED"); + } + @Test void overseasFeeOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + var configuration = new MybatisConfiguration(); + configuration.addMapper(LastlegEventOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource)); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3)); + assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse(); + assertThat(journal.begin("old", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue(); + jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61)); + assertThat(repository.claim(1L, "new", now)).isFalse(); + var next = now.plusSeconds(10); + assertThat(journal.complete("old", Outcome.LEASE_EXPIRED, now, "LEASE_EXPIRED", next, + () -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1); + assertThat(repository.claim(1L, "early", now)).isFalse(); + assertThat(journal.begin("new", "lastleg-service", "BusinessFeeSourceChanged", "1", null, "consumer", next, + () -> repository.claim(1L, "new", next))).isTrue(); + assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null, + () -> repository.dead(1L, "old", 12, "late"))).isFalse(); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isInstanceOf(RuntimeException.class); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PROCESSING"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id='new'", String.class)).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("new", Outcome.APPLIED, next, null, null, + () -> repository.published(1L, "new", next))).isTrue(); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox", String.class)).isEqualTo("PUBLISHED"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java index 24ad3ccf8..cf0f50759 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -27,8 +27,8 @@ class UnloadCompletedOutboxRepositoryTest { ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); - assertThat(repository.claim(1L, "attempt", now, now.minusMinutes(1))).isTrue(); - assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status", "locked_at"); + assertThat(repository.claim(1L, "attempt", now)).isTrue(); + assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status").doesNotContain("locked_at"); } @Test @@ -43,9 +43,9 @@ class UnloadCompletedOutboxRepositoryTest { when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); - assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + assertThat(repository.claim(1L, "worker", now)).isTrue(); assertThat(wrapper.getValue().getSqlSegment()) - .contains("next_retry_at", "<=", "status", "locked_at"); + .contains("next_retry_at", "<=", "status").doesNotContain("locked_at"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java deleted file mode 100644 index 0fca27fe0..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.baiyue.lastleg.security; - -import org.junit.jupiter.api.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class LastlegSecurityContractTest { - @Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception { - String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql"); - String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql"); - String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql"); - String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java"); - String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java"); - String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java"); - String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java"); - assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox", - "PRIMARY KEY (event_id)", "payload_hash CHAR(64)", - "CREATE TABLE IF NOT EXISTS ll_security_projection", - "PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT", - "permission_version BIGINT", "roles_json JSON", "authorities_json JSON"); - assertThat(baseline).contains("table_name='ll_security_projection' AND column_name='roles_json'", - "table_name='ll_security_projection' AND column_name='authorities_json'", - "UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL", - "UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL"); - assertThat(baseline).contains("SET @platform_admin_overseas_roles = JSON_ARRAY('PLATFORM_ADMIN')", - "SET @platform_admin_overseas_authorities = JSON_ARRAY(", - "('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0", - "('AUTHORIZATION','1000001',0,NULL,0,@platform_admin_overseas_roles,@platform_admin_overseas_authorities,0", - "baseline-platform-admin-user-v0", - "baseline-platform-admin-authorization-v0", - "platform:overseas-warehouse:last-mile-order:list", - "platform:overseas-warehouse:inbound-order:list", - "platform:overseas-warehouse:dispatch:list", - "platform:overseas-business:purchase:list", - "platform:overseas-master-data:quote:list", - "platform:finance:fee-source:review", - "AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'") - .doesNotContain("('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0", - "baseline-merchant-admin"); - assertThat(migration).contains( - "CREATE TABLE ll_security_event_inbox (", - "CREATE TABLE ll_security_projection (", - "VALUES ('AUTHORIZATION','1000001',0,NULL,0,", - "VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0", - "PLATFORM_ADMIN", - "baseline-platform-admin-user-v0", - "baseline-platform-admin-authorization-v0", - "platform:overseas-warehouse:last-mile-order:list", - "platform:overseas-warehouse:inbound-order:list", - "platform:overseas-warehouse:dispatch:list", - "platform:overseas-business:purchase:list", - "platform:overseas-master-data:quote:list", - "platform:finance:fee-source:review") - .doesNotContain("SET @platform_admin_overseas_roles", "UPDATE ll_security_projection", - "baseline-merchant-admin"); - assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id", - "default_app_code"); - assertThat(migration).contains("USE baiyue_lastleg;") - .doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security", - "USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox", - "CREATE TABLE IF NOT EXISTS overseas_security_projection"); - assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY"); - assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash") - .doesNotContain("LoadingCompletedConsumer"); - assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); - assertThat(validation).contains("SELECT permission_version", "authorities_json", "tenant_id=?", - "SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED", - "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); - assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion") - .doesNotContain("Long tokenVersion"); - } - - @Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception { - String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); - assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED", - "FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型") - .doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", - "IAM_PERMISSION_VERSION_CHANGED"); - } - - @Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception { - String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java"); - String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java"); - String application = read("lastleg-service/src/main/resources/application.yml"); - assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")", - "@PostMapping(\"/events\")", "@PostMapping(\"/validate\")", - "com.baiyue.lastleg.security.InternalServiceTokenGuard") - .doesNotContain("/internal/overseas-security/v1"); - assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"); - assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}") - .doesNotContain("phase1-local-internal-token"); - } - - private static String read(String relative) throws Exception { - Path path = Path.of(relative); - if (!Files.exists(path)) path = Path.of("..").resolve(relative); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java deleted file mode 100644 index 27a8de243..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.baiyue.lastleg.security; - -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.Test; - -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -class SecurityProjectionArchitectureTest { - private static final String LASTLEG = "com.baiyue.lastleg.."; - private static final String CONTROLLER = "com.baiyue.lastleg.security.controller.."; - private static final String DTO = "com.baiyue.lastleg.security.dto.."; - private static final String SERVICE = "com.baiyue.lastleg.security.service.."; - - private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) - .importPackages("com.baiyue.lastleg"); - - @Test - void projectionInternalsDoNotDependOnLastlegBusinessSlices() { - classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE) - .should().onlyDependOnClassesThat( - resideOutsideOfPackage(LASTLEG) - .or(resideInAPackage(CONTROLLER)) - .or(resideInAPackage(DTO)) - .or(resideInAPackage(SERVICE)) - .or(resideInAPackage("com.baiyue.lastleg.security"))) - .check(PRODUCTION_CLASSES); - } - - @Test - void businessSlicesDoNotDependOnProjectionInternals() { - noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE) - .should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE) - .check(PRODUCTION_CLASSES); - } - - @Test - void dtoAndServicesStayIndependentFromInternalWebAdapter() { - noClasses().that().resideInAnyPackage(DTO, SERVICE) - .should().dependOnClassesThat().resideInAPackage(CONTROLLER) - .check(PRODUCTION_CLASSES); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java deleted file mode 100644 index 42f93f8df..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.exception.BizException; -import com.baiyue.common.event.EventEnvelope; -import com.baiyue.common.event.EventPayloadCanonicalizer; -import com.baiyue.common.event.EventPayloadHash; -import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.IamSecurityEventsV1; -import com.baiyue.common.event.RegionCode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; - -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class LastlegSecurityProjectionTransportTest { - private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); - - @Test - void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() { - EventEnvelope envelope = envelope(); - - String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope); - - assertThat(stored).hasSize(64).doesNotStartWith("sha256:"); - assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored); - } - - @Test - void rejectsTamperedPayloadBeforeInboxMutation() { - EventEnvelope source = envelope(); - EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), - source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), - source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), - source.traceId(), source.correlationId(), source.causationId(), source.payload(), - "sha256:" + "0".repeat(64)); - - assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered)) - .isInstanceOf(BizException.class).hasMessageContaining("payloadHash"); - } - - private EventEnvelope envelope() { - var value = new IamSecurityEventsV1.PermissionVersionChanged( - 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"), - java.util.Set.of("PLATFORM_ADMIN"), - java.util.Set.of("platform:overseas-warehouse:last-mile-order:list")); - var payload = objectMapper.valueToTree(value); - return new EventEnvelope("00000000-0000-4000-8000-000000000091", - EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, - Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0", - "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", - null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java deleted file mode 100644 index 3bf58ccf4..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; -import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; - -class LastlegSecurityValidationServiceTest { - private final JdbcTemplate jdbc = mock(JdbcTemplate.class); - private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc, new ObjectMapper()); - - @Test void disabledUserFailsClosed() { - doReturn(List.of("DISABLED")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) - .isEqualTo(FailureReason.USER_DISABLED); - } - - @Test void disabledCustomerFailsClosed() { - doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) - .isEqualTo(FailureReason.TENANT_DISABLED); - } - - @Test void missingUserProjectionFailsClosed() { - doReturn(List.of()).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); - } - - @Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() { - doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L), - List.of("[]\n[\"platform:overseas-warehouse:last-mile-order:list\"]")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue(); - } - - @Test void permissionVersionMustExactlyMatchReliableProjection() { - doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L), - List.of("ACTIVE"), List.of(9L), List.of("[]\n[]")).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) - .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); - assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY); - assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue(); - } - - @Test void missingPermissionProjectionFailsClosed() { - doReturn(List.of("ACTIVE"), List.of()).when(jdbc) - .query(anyString(), any(RowMapper.class), any(Object[].class)); - assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason()) - .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java deleted file mode 100644 index b385e2aea..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.lastleg.security.service; - -import com.baiyue.common.core.exception.BizException; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class SecurityProjectionMonotonicityTest { - @Test void appliesFirstAndNewerVersions() { - assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); - assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); - } - - @Test void ignoresOlderAndSameHashVersions() { - assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.STALE); - assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) - .isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION); - } - - @Test void rejectsSameVersionWithDifferentHash() { - assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) - .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 16dd17461..c6c9c3a00 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -6,18 +6,18 @@ class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } - @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } + @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } - @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } + @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } - @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } + @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId")); @@ -25,8 +25,8 @@ class LoadingCompletedConsumerTest { String table=schema.substring(schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order ("),schema.indexOf("CREATE TABLE IF NOT EXISTS ll_inbound_order_item (")); assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } - @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void freezesReceivingWarehouseInInitialInTransitInventory(){ service.consume(event()); ArgumentCaptor captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); @@ -64,8 +64,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) .set("cartonMarks", json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor orderCaptor=ArgumentCaptor.forClass(LastlegOrder.class); @@ -83,8 +82,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(v2)); assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数")); @@ -97,8 +95,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) .set("cartonMarks",json.valueToTree(List.of("LAX9-BOX-1","LAX9-BOX-2","LAX9-BOX-3"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); @@ -108,8 +105,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA"); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), - source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); @@ -124,7 +120,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5"))); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); @@ -224,7 +220,7 @@ class LoadingCompletedConsumerTest { verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class)); } private EventEnvelope rehash(EventEnvelope original,JsonNode node) { - return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); + return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node))); } private EventEnvelope overseasDropshipEvent(boolean duplicateSku) { EventEnvelope original=event(); @@ -239,15 +235,15 @@ class LoadingCompletedConsumerTest { if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy()); node.putArray("orders").add(sourceOrder); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash); + return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash); } - private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } + private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } @Test void projectsFiveDomesticLoadingPhotosOncePerPhysicalContainer(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(source); var values=node.putArray("loadingEvidence"); int fileId=10; for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class); verify(loadingEvidence,times(5)).insert(captured.capture()); @@ -257,13 +253,13 @@ class LoadingCompletedConsumerTest { @Test void rejectsV3EventWithoutTheFiveDomesticLoadingPhotos(){ EventEnvelope source=event(); JsonNode node=v2Payload(source); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); - EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("五类国内装柜照片")); verify(loadingEvidence,never()).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class)); } private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; } - private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } + private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index 6c73a9d5b..a9ea0af18 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventInbox; import com.baiyue.lastleg.mapper.LastlegEventInboxMapper; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; @@ -101,7 +100,7 @@ class ContainerShippingNodeConsumerTest { EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5, Instant.parse("2026-08-21T10:00:00Z")); EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), - source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.occurredAt(), source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), source.payload(), "sha256:" + "0".repeat(64)); @@ -126,7 +125,7 @@ class ContainerShippingNodeConsumerTest { JsonNode payloadNode = json.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, - actualAt, RegionCode.CN, RegionCode.OVERSEAS, "0", "CONTAINER_BOOKING", "CB-1", + actualAt, "0", "CONTAINER_BOOKING", "CB-1", version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null, payloadNode, hash); } diff --git a/order/src/main/resources/application-dev.yml b/order/src/main/resources/application-dev.yml deleted file mode 100644 index bef45e064..000000000 --- a/order/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${ORDER_DB_USERNAME:root} - password: ${ORDER_DB_PASSWORD:123456} diff --git a/order/src/main/resources/application-test.yml b/order/src/main/resources/application-test.yml deleted file mode 100644 index 8e0bc7e3b..000000000 --- a/order/src/main/resources/application-test.yml +++ /dev/null @@ -1,17 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${ORDER_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${ORDER_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${ORDER_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} - -baiyue.order.inbound-code-mode: AUTO diff --git a/order/src/main/resources/application.yml b/order/src/main/resources/application.yml index a0883c3c1..7e7ae8b20 100644 --- a/order/src/main/resources/application.yml +++ b/order/src/main/resources/application.yml @@ -2,32 +2,31 @@ server: port: ${ORDER_PORT:9204} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: order-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 management.endpoints.web.exposure.include: health,info,prometheus baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} clients: iam: url: ${IAM_SERVICE_URL:} diff --git a/pom.xml b/pom.xml index 73064fb44..6755a57e4 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,6 @@ common-audit common-event-contract gateway - gateway-overseas auth iam order @@ -38,7 +37,6 @@ audit-service finance integration-service - event-bridge-service diff --git a/print/AGENTS.md b/print/AGENTS.md index 0e81b655c..3e9af5ab7 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -24,7 +24,7 @@ | PDF 参数配置 | `config/PrintPdfProperties.java` | | 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` | | 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` | -| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` | +| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/print-service.yml` | | 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` | ## 修改与验证 diff --git a/print/src/main/resources/application.yml b/print/src/main/resources/application.yml index e01f7b2b9..221b6d870 100644 --- a/print/src/main/resources/application.yml +++ b/print/src/main/resources/application.yml @@ -2,27 +2,26 @@ server: port: ${PRINT_PORT:9207} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: print-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:} print: pdf: diff --git a/scripts/README.md b/scripts/README.md index 7e44caf20..203657136 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,7 +16,6 @@ `db/test-return-relabel-checks.sql`:测试库退货转换标检查约束定向修复,显式目标 `baiyue_lastleg.ll_return_inventory_carton`。与 Lastleg 基线保持一致,允许正数量的 `RELABEL_PENDING`,保留可用/已出库正数量与已合箱零数量限制。一条原子 ALTER 同时替换两个既有 CHECK,不修改业务行、不删除数据、不关闭约束;重复执行得到相同约束,旧约束不存在则报错停止。凭据由外部测试数据库连接提供,执行前需测试库操作授权;末尾只读输出约束定义供回查。不是生产迁移入口,不能替代完整 HTTP 换标回归。 -`../deploy/scripts/deploy-blue-green-service.sh`:受控部署流程调用的单服务蓝绿发布与健康检查入口;只由部署/Jenkins 编排使用,不作为普通验收脚本直接运行。目标服务、环境、镜像和凭据均由外部参数提供,失败按部署脚本回滚规则处理。 `db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;修正未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单,并将已客户确认且换标完成、仍停在待换标/待分配的仓储转运单按目的仓恢复为待排车或待确认目的仓,同步尾程主单和换标履约状态;不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 @@ -159,9 +158,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 | | 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 | | 本地环境 | `start-gateway-overseas-local.ps1` | 使用本地 Maven 启动海外 Gateway,并将海外安全投影地址指向本机;会启动本地 Java 进程,不访问或修改共享环境 | -| 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 | -| Nacos 环境选择 | `use-nacos-environment.ps1` | 按显式环境与区域设置当前 PowerShell 进程的 Nacos、Spring Profile 和运行区域变量;开发/测试密码必须由参数或环境变量提供,不发布配置 | -| Nacos 配置发布 | `publish-nacos-config.ps1` | 将 `config/nacos/{local|cn|overseas}/*.yml` 发布到显式指定的 Nacos;非本地环境强制要求非 public namespace,会修改目标 Nacos 配置,执行前须取得对应环境授权 | +| Nacos 环境选择 | `use-nacos-environment.ps1` | 仅设置当前进程 TEST 配置;显式提供地址、实际Namespace ID与凭据,不联网、不写运行环境 | +| Nacos 配置发布 | `publish-nacos-config.ps1` | 仅发布 `config/nacos/test/*.yml`;必须指定实际Namespace ID,凭据从环境取得。`-DryRun`离线列清单;正式运行会写Nacos,需授权;失败停止,重跑完整匹配配置,不自动清理 | | 密钥配置 | `generate-overseas-jwk.mjs` | 生成 3072 位海外 JWT RSA JWK 并写入指定 `deploy/env/.env.*`;已有 JWK 时拒绝覆盖,密钥文件不得提交 | | 迁移辅助 | `migrate-local-qualification-file.ps1` | 本地资质文件迁移辅助,会修改本地文件/数据;运行前核对脚本参数和备份策略 | | Jenkins 管理 | `provision-development-service-jobs.groovy` | Jenkins Script Console 批量准备开发服务 Job;默认 `applyChanges=false` 仅预览,改为 true 会创建 Job | @@ -356,3 +354,9 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 - `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。 - `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。 - `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。 + +部署离线合同:`overseas-deployment-contract.test.mjs` 现验证统一TEST拓扑,保留历史文件名便于调用;`deploy-built-service-rollback.test.mjs`以mock Docker验证失败与同契约回滚,无真实环境操作。唯一执行清单见 `../deploy/TEST单机部署执行清单.md`。 +# 事件人工重试运维入口 + +`event-outbox-operations.mjs`:单测试环境的内部运维脚本。只连接显式指定的生产服务地址,不通过公网 Gateway,不自动重试或跟随重定向。凭据只从进程环境 `EVENT_OPERATIONS_TOKEN` 读取,另须 `CHONGCHONG_ENV=test`;不要把真实凭据放入参数或仓库。`--service firstleg|lastleg|finance --base <服务源地址> --entry ` 只读查询状态和摘要,不返回业务负载。追加 `--retry --request-id <稳定UUID> --operator <操作人> --reason <原因> --hash ` 会写入目标测试服务的 Outbox 与审计表,执行前须已有该环境操作授权。仅 DEAD 原事件可重试,事件身份、负载及摘要保持不变;重复提交使用同一个请求编号,参数变化会被拒绝。三个服务须先部署 `event_delivery_retry_audit` 表,未设置至少32字节的运维凭据时入口关闭。脚本将脱敏结果输出到标准输出,不持久化凭据;离线验证为 `node --test scripts/event-outbox-operations.test.mjs`。 + diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 8de5b7f41..c7c5c6e2b 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -5,11 +5,11 @@ import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities const cn = readInitialization() const overseas = readInitialization('OVERSEAS') const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse', - 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', 'baiyue_finance'] -const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse'] + 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_finance'] +const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse'] -test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 138]]) { +test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 223], [overseas, overseasSchemas, domesticSchemas, 135]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|138) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (223|135) AS initialized_tables;$/) } }) @@ -53,7 +53,7 @@ test('必要系统种子完整保留,但不写入示例资源与业务事实', file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) for (const name of ['wh_pallet', 'wh_location', 'wh_partner', 'wh_partner_type', 'wh_express_company', 'ord_order', 'fl_inbound_order', 'fin_fee_source']) assert.equal(seedRows(cn, name).length, 0, name) - assert.deepEqual([...new Set([...overseas.matchAll(/^INSERT INTO (\w+)/gm)].map(m => m[1]))], ['ll_security_projection']) + assert.deepEqual([...new Set([...overseas.matchAll(/^INSERT INTO (\w+)/gm)].map(m => m[1]))], []) assert.ok(seedRows(cn, 'wh_warehouse').every(r => r.tenant_id === '0')) assert.ok(seedRows(cn, 'wh_service_capability').every(r => r.service_type === 'RECEIVING' || r.capability_code === 'RECEIVING' || Object.values(r).includes('RECEIVING'))) }) @@ -98,19 +98,13 @@ test('近期头程与海外结构完整进入各自统一初始化入口', () => /shared_overflow tinyint\(1\) NOT NULL DEFAULT '?0'?/i) }) -test('海外固定身份投影完全匹配国内管理员的当前海外权限', () => { - const rows = seedRows(overseas, 'll_security_projection') - assert.equal(rows.length, 2) - const authorization = rows.find(r => r.projection_type === 'AUTHORIZATION' || r.subject_type === 'AUTHORIZATION' || Object.values(r).includes('AUTHORIZATION')) - assert.ok(authorization) - const authorities = JSON.parse(authorization.authorities_json) - const expected = [...roleAuthorities(cn, 'PLATFORM_ADMIN')].filter(code => - /^(platform:finance:|platform:overseas-business:|platform:overseas-master-data:|platform:overseas-warehouse:)/.test(code)) - assert.equal(authorities.length, 106) - assert.equal(new Set(authorities).size, authorities.length) - assert.deepEqual([...authorities].sort(), expected.sort()) - assert.deepEqual(JSON.parse(authorization.roles_json), ['PLATFORM_ADMIN']) - for (const row of rows) assert.ok(Object.values(row).includes('1000001')) +test('统一认证保留持久版本,不再初始化安全投影或桥接发件箱', () => { + for (const retired of ['iam_security_event_outbox', 'll_security_event_inbox', 'll_security_projection']) { + assert.doesNotMatch(cn + overseas, new RegExp('CREATE TABLE ' + retired + '\\b')) + } + assert.ok(table(cn, 'baiyue_iam', 'iam_security_subject_version')) + assert.ok(table(cn, 'baiyue_iam', 'iam_security_tenant_version')) + assert.equal(seedRows(overseas, 'll_security_projection').length, 0) }) test('跨区域放错位置的最终字段归正确区域且保留财务精度', () => { diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 44cbc3fa2..cb39c70ec 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -534,7 +534,6 @@ Assert-Matches $cabinetEvidenceService '(?s)mapper\.byOperation\(taskId, key\).{ $mapperModuleAgents = @( @{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' }, @{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' }, - @{ Module = 'event-bridge-service'; Application = 'EventBridgeApplication.java' }, @{ Module = 'file'; Application = 'FileServiceApplication.java' }, @{ Module = 'firstleg'; Application = 'BaiyueFirstlegApplication.java' }, @{ Module = 'iam'; Application = 'BaiyueIamApplication.java' }, @@ -618,29 +617,24 @@ foreach ($route in @( Assert-Matches $clientOverseasRoutes $routePattern "Client 正式路由 $route" } -# 根索引中的海外浏览器 API 必须被海外 Gateway 显式覆盖。 -$overseasGatewayConfigPath = Join-Path $backendRoot 'gateway-overseas/src/main/resources/application-overseas.yml' -$overseasGatewayConfig = Read-RequiredText $overseasGatewayConfigPath -foreach ($gatewayPath in @( - '/api/merchant/v1/last-leg/**', - '/api/platform/v1/last-leg/**', - '/api/pda/v1/last-leg/**', - '/api/platform/v1/warehouses/**', - '/api/platform/v1/warehouse-admin/**' -)) { - Assert-Contains $overseasGatewayConfig $gatewayPath 'Overseas Gateway 路由' +# 四端统一 Gateway,保留服务与会话均来自 TEST 配置。 +$unifiedGateway = Read-RequiredText (Join-Path $backendRoot 'gateway/src/main/resources/application.yml') +foreach ($gatewayPath in @('/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', '/api/pda/v1/last-leg/**', '/api/platform/v1/warehouse-admin/**', '/api/platform/v1/overseas-warehouse-resources/**')) { + Assert-Contains $unifiedGateway $gatewayPath '统一 Gateway 路由' } -Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' -Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' - -# 海外安全投影已并入 Lastleg,Gateway 校验和 Event Bridge 消费均指向同一海外服务。 +Assert-Contains $unifiedGateway 'uri: lb://lastleg-service' 'Lastleg 服务路由' +Assert-Contains $unifiedGateway 'uri: lb://warehouse-overseas-service' '海外仓资源路由' $serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') -$overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') +$testCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.yml') $serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') -Assert-Contains $serviceMap 'lastleg-service:' 'Lastleg 服务部署映射' -Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg 安全事件消费地址' -Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' -Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' +Assert-Contains $testCompose 'AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate' '统一权威会话校验' +Assert-Contains $testCompose 'REDIS_TOKEN_KEY_PREFIX' '统一会话前缀' +Assert-Contains $testCompose 'SPRING_PROFILES_ACTIVE: test' '唯一 TEST profile' +foreach ($retired in @('gateway-overseas:', 'event-bridge-service:')) { + Assert-NotContains $serviceMap $retired '部署映射' + Assert-NotContains $testCompose $retired 'TEST Compose' +} +Assert-Contains $backendAgents 'config/nacos/test/' '唯一 Nacos 配置入口' # 高变化语义必须在最近指引与服务契约中保持一致。 Assert-Contains $adminAgents '海外模块内部快速索引(海外仓全流程)' 'Admin 海外仓全流程范围' @@ -842,7 +836,6 @@ Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合 Assert-Contains $pdaAgents 'tests/pda-loading-api.test.mjs' '国内 PDA API 合同' # 国内柜费分摊与海外费用跨区入池必须能从根索引直达,并锁住归属、金额、版本与直接已付隔离。 $financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md') -$eventBridgeAgents = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/AGENTS.md') $commonEventAgents = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/AGENTS.md') $adminFinanceAgents = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/finance/AGENTS.md') $containerFeeOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/container-fees-v1.yaml') @@ -851,7 +844,8 @@ $containerFeeService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m $overseasFinanceOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml') $businessFeeEvent = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java') $eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java') -$overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java') +$directEventClient = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java') +$financeBusinessClient = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java') $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java') $financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java') $overseasFinancePhase0ContractPath = Join-Path $workspaceRoot 'docs/财务管理/海外仓费用统一接入阶段0技术契约.md' @@ -889,11 +883,10 @@ Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceCha Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本' Assert-Contains $businessFeeEvent 'List paymentVoucherFileIds' '海外费用事件付款证据快照' Assert-Contains $businessFeeEvent 'String sourceHash' '海外费用事件来源哈希' -Assert-Contains $eventBridgeAgents '`BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance' 'Event Bridge Finance 定向路由' -Assert-Contains $eventBridgeAgents '/internal/finance/v1/business-fee-source-events' 'Event Bridge Finance 内部入口' -Assert-Contains $overseasDispatcher 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED' 'Event Bridge 海外费用事件 Finance 路由集合' -Assert-Contains $overseasDispatcher 'EventTypes.WALLET_COMMAND_REQUESTED' 'Event Bridge 海外资金指令 Finance 路由集合' -Assert-Matches $overseasDispatcher '(?s)FINANCE_EVENT_TYPES\.contains\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge Finance 事件定向投递' +Assert-Contains $directEventClient 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> checkedFinance(finance.fee(token, event))' '费用事件定向 Finance' +Assert-Contains $directEventClient 'EventTypes.WALLET_COMMAND_REQUESTED -> checkedFinance(finance.wallet(token, event))' '资金指令定向 Finance' +Assert-Contains $financeBusinessClient '/internal/finance/v1/business-fee-source-events' 'Finance 费用内部入口' +Assert-Contains $financeBusinessClient '/internal/finance/v1/wallet-command-events' 'Finance 资金内部入口' Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界' Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界' Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由' @@ -927,6 +920,8 @@ Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw +if (@($pom.project.modules.module).Count -ne 20) { Add-Failure '当前聚合必须为20模块(7公共/依赖+13服务)' } +if ([regex]::Matches($serviceMap, '(?m)^ [a-z][a-z-]+:').Count -ne 13) { Add-Failure '当前发布映射必须为13服务' } foreach ($module in $pom.project.modules.module) { Assert-Contains $backendAgents "$module/" '后端 AGENTS.md' } @@ -1085,10 +1080,8 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs', 'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java', - 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java', - 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java', - 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java', @@ -1097,15 +1090,13 @@ $criticalPaths = @( 'baiyue-firstleg/iam/src/main/resources/db/customer-dimension-initialization-task.sql', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', + 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', + 'baiyue-firstleg/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java', 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java', 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java', 'baiyue-firstleg/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java', - 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', - 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java', @@ -1148,8 +1139,6 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java', 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java', 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java', - 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', - 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java', @@ -1181,6 +1170,13 @@ $criticalPaths = @( 'docs/财务管理/Firstleg-Finance跨服务契约.md' ) $criticalPaths | ForEach-Object { Assert-Path $_ } +# phase4前端定向合同是本地测试入口;不代表真实页面或环境验收通过。 +foreach ($frontendTestPath in @('apps/admin-web/vitest.phase4-test.config.ts', 'apps/admin-web/scripts/phase4-test-frontend.acceptance.ts', 'apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs', 'apps/admin-web/scripts/phase4-test-frontend-contract.test.mjs')) { + Assert-Path $frontendTestPath +} +$adminPackage = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/package.json') +Assert-Contains $adminPackage '"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs"' 'Admin phase4 TEST入口' + foreach ($removedOverseasWarehouseMasterPath in @( 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java', @@ -1213,7 +1209,6 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), (Join-Path $backendRoot 'warehouse/AGENTS.md'), - (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') ) @@ -1252,5 +1247,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、统一 Token 与权威会话、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/audit-deployment-contract.test.mjs b/scripts/audit-deployment-contract.test.mjs index 09dd959af..03b984051 100644 --- a/scripts/audit-deployment-contract.test.mjs +++ b/scripts/audit-deployment-contract.test.mjs @@ -9,10 +9,10 @@ test('基础 Compose 显式配置审计服务地址,避免静默退化为日 const compose = await read('deploy/docker-compose.yml') assert.match(compose, - /BAIYUE_AUDIT_ENDPOINT: \$\{BAIYUE_AUDIT_ENDPOINT:-http:\/\/audit-service:9700\/internal\/audit\/events\}/) + /BAIYUE_AUDIT_ENDPOINT: http:\/\/audit-service:9700\/internal\/audit\/events/) assert.doesNotMatch(compose, /^\s+AUDIT_SERVICE_URL:/m) assert.match(compose, /^ audit-service:\s*$/m) - assert.match(compose, /container_name: firstleg-\$\{DEPLOY_ENV:-test\}-audit/) + assert.match(compose, /container_name: firstleg-test-audit/) assert.match(compose, /networks: \[firstleg-network, infra-network\]/) }) diff --git a/scripts/blue-green-deployment-contract.test.mjs b/scripts/blue-green-deployment-contract.test.mjs deleted file mode 100644 index 6abd984a6..000000000 --- a/scripts/blue-green-deployment-contract.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import test from 'node:test' - -const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('all backend business services have blue-green runtime contracts', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - for (const service of [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'audit-service', 'gateway-overseas', 'lastleg-service', - 'warehouse-overseas-service', 'event-bridge-service' - ]) assert.match(script, new RegExp(`${service.replaceAll('-', '\\-')}:`)) - - assert.match(script, /target_slot="green"/) - assert.match(script, /target_port=\$\(\(BASE_PORT \+ 20000\)\)/) - assert.match(script, /target_port=\$\(\(BASE_PORT \+ 10000\)\)/) - assert.match(script, /compose run -d --no-deps --name "\$candidate"/) - assert.match(script, /wait_for_container "\$candidate"/) - assert.match(script, /BAIYUE_RUNTIME_SCHEDULING_ENABLED=false/) - assert.match(script, /NACOS_REGISTER_ENABLED=false/) - assert.match(script, /start_worker_candidate/) - assert.match(script, /worker_java_options="-Xms512m -Xmx2g/) - assert.match(script, /worker_java_options="-Xms256m -Xmx1g/) - assert.match(script, /connect_routing_aliases "\$candidate" "\$ROUTING_ALIAS" "\$NACOS_SERVICE"/) - assert.match(script, /file,file-service:file/) - assert.match(script, /docker stop --time "\$STOP_TIMEOUT" "\$active_container"/) - assert.match(script, /DRAIN_SECONDS="\$\{BLUE_GREEN_DRAIN_SECONDS:-30\}"/) - assert.match(script, /蓝绿切换后连接排空/) - assert.match(script, /sleep "\$DRAIN_SECONDS"/) - assert.match(script, /mv -f "\$STATE_FILE\.tmp" "\$STATE_FILE"/) -}) - -test('candidate failure keeps the active container and successful cutover stops it last', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - const start = script.lastIndexOf('compose run -d') - const healthy = script.indexOf('if ! wait_for_container "$candidate"') - const route = script.indexOf('connect_routing_aliases "$candidate"') - const gateway = script.indexOf('if ! switch_gateway_proxy') - const stop = script.indexOf('docker stop --time "$STOP_TIMEOUT" "$active_container"') - const state = script.indexOf('mv -f "$STATE_FILE.tmp" "$STATE_FILE"') - assert.ok(start >= 0 && start < healthy) - assert.ok(healthy < route && route < gateway && gateway < stop && stop < state) - assert.match(script.slice(healthy, route), /cleanup_candidate/) -}) - -test('gateway cutover is atomic and restores nginx config on validation failure', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - assert.match(script, /00-blue-green-gateway\.conf/) - assert.match(script, /00-blue-green-gateway-overseas\.conf/) - assert.doesNotMatch(script, /api-cn\.buggoglobal\.com\.conf|api-global\.buggoglobal\.com\.conf/) - assert.match(script, /尚未完成首次蓝绿迁移,拒绝切换 Gateway/) - assert.match(script, /frontend-\$frontend_service\.active/) - assert.match(script, /尚未使用稳定上游 \$required_alias,拒绝切换 Gateway/) - assert.match(script, /reload_frontend_proxies/) - assert.match(script, /cp -f "\$config" "\$backup"/) - assert.match(script, /nginx -t \|\| ! nginx -s reload/) - assert.match(script, /cp -f "\$backup" "\$config"/) -}) - -test('compose templates preserve rolling defaults and expose slot metadata', async () => { - const domestic = await read('deploy/docker-compose.yml') - const overseas = await read('deploy/docker-compose.overseas.yml') - for (const compose of [domestic, overseas]) { - assert.match(compose, /BAIYUE_DEPLOY_SLOT: \$\{DEPLOY_SLOT:-rolling\}/) - assert.match(compose, /BAIYUE_RELEASE_ID: \$\{RELEASE_ID:-local\}/) - assert.match(compose, /SERVER_SHUTDOWN: graceful/) - assert.match(compose, /stop_grace_period: \$\{STOP_GRACE_PERIOD:-45s\}/) - assert.match(compose, /\$\{DEPLOY_SLOT_SUFFIX:-\}/) - } -}) - -test('every scheduled worker service can disable Nacos registration', async () => { - for (const application of [ - 'iam/src/main/resources/application.yml', - 'firstleg/src/main/resources/application.yml', - 'finance/src/main/resources/application.yml', - 'order/src/main/resources/application.yml', - 'file/src/main/resources/application.yml', - 'event-bridge-service/src/main/resources/application.yml', - 'audit-service/src/main/resources/application.yml', - 'lastleg-service/src/main/resources/application.yml' - ]) { - assert.match(await read(application), /register-enabled: \$\{NACOS_REGISTER_ENABLED:true\}/, application) - } -}) - -test('production single-service pipeline dispatches the blue-green executor', async () => { - const deploy = await read('deploy/scripts/deploy-built-service.sh') - const batch = await read('deploy/scripts/deploy-services.sh') - assert.match(deploy, /run_scp "deploy\/scripts\/deploy-blue-green-service\.sh"/) - assert.match(deploy, /if \[ "\$DEPLOY_ENV" = "production" \]/) - assert.match(deploy, /run_ssh sh "\$REMOTE_DIR\/\$REMOTE_BLUE_GREEN_NAME"/) - assert.match(deploy, /TARGET_IS_LOCAL[\s\S]*?deploy-blue-green-service\.sh/) - assert.match(batch, /sh deploy\/scripts\/deploy-blue-green-service\.sh/) -}) - -test('test environment uses single-slot compose updates', async () => { - const deploy = await read('deploy/scripts/deploy-built-service.sh') - const batch = await read('deploy/scripts/deploy-services.sh') - assert.doesNotMatch(deploy, /if \[ "\$DEPLOY_ENV" = "test" \] \\\n\s*\|\|[\s\S]*?deploy-blue-green-service\.sh/) - assert.match(batch, /if \[ "\$DEPLOY_ENV" = test \]; then[\s\S]*?up -d --no-deps --pull never "\$service"/) -}) diff --git a/scripts/blue-green-deployment-runtime.test.mjs b/scripts/blue-green-deployment-runtime.test.mjs deleted file mode 100644 index 6e4a7026a..000000000 --- a/scripts/blue-green-deployment-runtime.test.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import assert from 'node:assert/strict' -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { delimiter, join, resolve } from 'node:path' -import { tmpdir } from 'node:os' -import { spawnSync } from 'node:child_process' -import test from 'node:test' - -const root = resolve(import.meta.dirname, '..') -const script = resolve(root, 'deploy/scripts/deploy-blue-green-service.sh') -const shell = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : '/bin/sh' - -const dockerMock = `#!/usr/bin/env sh -set -eu -printf '%s\\n' "$*" >> "$MOCK_LOG" -command="$1" -shift -case "$command" in - network) - [ "$1" = inspect ] && { echo '{}'; exit 0; } - exit 0 - ;; - compose) - container_name='' - previous='' - for argument in "$@"; do - if [ "$previous" = --name ]; then container_name="$argument"; fi - previous="$argument" - if [ "$argument" = run ]; then - action=run - fi - done - if [ "\${action:-}" = run ]; then - case "$container_name" in - *-worker) : > "$MOCK_WORKER_EXISTS" ;; - *) : > "$MOCK_CANDIDATE_EXISTS" ;; - esac - exit 0 - fi - exit 0 - ;; - inspect) - target='' - for argument in "$@"; do target="$argument"; done - case "$target" in - firstleg-production-file) - [ ! -f "$MOCK_LEGACY_REMOVED" ] - ;; - firstleg-production-file-green) - [ -f "$MOCK_CANDIDATE_EXISTS" ] || exit 1 - case "$*" in - *State.Health*) printf '%s\\n' "\${MOCK_CANDIDATE_HEALTH:-healthy}" ;; - *Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;; - esac - ;; - firstleg-production-file-green-worker) - [ -f "$MOCK_WORKER_EXISTS" ] || exit 1 - case "$*" in - *State.Health*) printf '%s\\n' healthy ;; - *Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;; - esac - ;; - *) exit 1 ;; - esac - ;; - stop) - target='' - for argument in "$@"; do target="$argument"; done - [ "$target" = firstleg-production-file ] && : > "$MOCK_LEGACY_STOPPED" - ;; - rm) - target='' - for argument in "$@"; do target="$argument"; done - case "$target" in - firstleg-production-file) : > "$MOCK_LEGACY_REMOVED" ;; - firstleg-production-file-green) rm -f "$MOCK_CANDIDATE_EXISTS" ;; - firstleg-production-file-green-worker) rm -f "$MOCK_WORKER_EXISTS" ;; - esac - ;; - logs) echo 'candidate logs' ;; - *) echo "unexpected docker command: $command $*" >&2; exit 2 ;; -esac -` - -async function runBlueGreen(health) { - const temp = await mkdtemp(join(tmpdir(), 'blue-green-runtime-')) - const bin = join(temp, 'bin') - const docker = join(bin, 'docker') - const sleep = join(bin, 'sleep') - const envFile = join(temp, 'deployment.env') - const compose = join(temp, 'compose.yml') - const stateRoot = join(temp, 'state') - const log = join(temp, 'docker.log') - await mkdir(bin) - await mkdir(stateRoot) - await writeFile(docker, dockerMock, 'utf8') - await writeFile(sleep, '#!/usr/bin/env sh\nexit 0\n', 'utf8') - await chmod(docker, 0o755) - await chmod(sleep, 0o755) - await writeFile(envFile, 'FILE_EXPORT_CONSUMER_ENABLED=true\n', 'utf8') - await writeFile(compose, 'services: {}\n', 'utf8') - await writeFile(log, '', 'utf8') - - const paths = { - candidate: join(temp, 'candidate'), stopped: join(temp, 'legacy-stopped'), - removed: join(temp, 'legacy-removed'), worker: join(temp, 'worker') - } - const result = spawnSync(shell, [ - script.replaceAll('\\', '/'), 'production', 'registry.local/group', 'newtag', 'file', - envFile.replaceAll('\\', '/'), compose.replaceAll('\\', '/'), 'firstleg-production' - ], { - cwd: root, encoding: 'utf8', env: { - ...process.env, - PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, - BLUE_GREEN_STATE_ROOT: stateRoot, - BLUE_GREEN_STABILIZATION_SECONDS: '0', - BLUE_GREEN_DRAIN_SECONDS: '0', - DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', - DEPLOY_HEALTH_POLL_SECONDS: '1', - MOCK_LOG: log, - MOCK_CANDIDATE_EXISTS: paths.candidate, - MOCK_WORKER_EXISTS: paths.worker, - MOCK_LEGACY_STOPPED: paths.stopped, - MOCK_LEGACY_REMOVED: paths.removed, - MOCK_CANDIDATE_HEALTH: health - } - }) - const output = `${result.stdout}\n${result.stderr}` - const dockerLog = await readFile(log, 'utf8') - const stateFile = join(stateRoot, 'firstleg-production-file.active') - const state = existsSync(stateFile) ? await readFile(stateFile, 'utf8') : '' - const evidence = { - ...result, output, dockerLog, state, - stopped: existsSync(paths.stopped), removed: existsSync(paths.removed), - worker: existsSync(paths.worker) - } - await rm(temp, { recursive: true, force: true }) - return evidence -} - -test('healthy candidate becomes active only after health and old instance drains', async (context) => { - if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) - const result = await runBlueGreen('healthy') - assert.equal(result.status, 0, result.output) - assert.equal(result.stopped, true) - assert.equal(result.removed, true) - assert.equal(result.worker, true) - assert.match(result.state, /^green\|firstleg-production-file-green\|19206\|registry\.local\/group\/file:newtag\|firstleg-production-file-green-worker/) - const run = result.dockerLog.indexOf('compose --project-name firstleg-production') - const stop = result.dockerLog.indexOf('stop --time 45 firstleg-production-file') - assert.ok(run >= 0 && run < stop) - assert.match(result.dockerLog, - /network connect --alias file --alias file-service firstleg-production-routing firstleg-production-file-green/) -}) - -test('unhealthy candidate is removed while old instance and state remain untouched', async (context) => { - if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) - const result = await runBlueGreen('unhealthy') - assert.equal(result.status, 1, result.output) - assert.equal(result.stopped, false) - assert.equal(result.removed, false) - assert.equal(result.worker, false) - assert.equal(result.state, '') - assert.doesNotMatch(result.dockerLog, /stop --time .*firstleg-production-file/) -}) diff --git a/scripts/container-logging-contract.test.mjs b/scripts/container-logging-contract.test.mjs index 3fa84cdc5..4c2d75a61 100644 --- a/scripts/container-logging-contract.test.mjs +++ b/scripts/container-logging-contract.test.mjs @@ -5,12 +5,10 @@ import { test } from 'node:test'; const applicationConfigs = [ 'auth/src/main/resources/application.yml', 'audit-service/src/main/resources/application.yml', - 'event-bridge-service/src/main/resources/application.yml', 'file/src/main/resources/application.yml', 'finance/src/main/resources/application.yml', 'firstleg/src/main/resources/application.yml', 'gateway/src/main/resources/application.yml', - 'gateway-overseas/src/main/resources/application.yml', 'iam/src/main/resources/application.yml', 'integration-service/src/main/resources/application.yml', 'lastleg-service/src/main/resources/application.yml', @@ -18,14 +16,11 @@ const applicationConfigs = [ 'print/src/main/resources/application.yml', 'warehouse/src/main/resources/application.yml', 'warehouse-overseas-service/src/main/resources/application.yml', - 'config/nacos/cn/baiyue-common.yml', - 'config/nacos/local/baiyue-common.yml', - 'config/nacos/overseas/baiyue-common.yml', + 'config/nacos/test/baiyue-common.yml', ]; const composeFiles = [ 'deploy/docker-compose.yml', - 'deploy/docker-compose.overseas.yml', ]; const infrastructureComposeFiles = [ diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 5af5926f3..e5cb45c1d 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -110,24 +110,25 @@ test('constraint tightening is guarded before any alter table', async () => { } }) -test('数据库迁移计划包含本批四个 schema 的正式目标版本', async () => { +test('数据库迁移计划包含授权版本和事件可靠投递的正式目标版本', async () => { const sqlRoot = path.join(repositoryRoot, 'deploy', 'sql') const scopes = await loadScopes(path.join(sqlRoot, 'migration-scopes.json')) const plan = await buildPlan({ sqlRoot, scopes, regions: ['CN', 'OVERSEAS'], - services: ['iam', 'firstleg', 'lastleg-service', 'warehouse-overseas-service'], + services: ['iam', 'firstleg', 'finance', 'lastleg-service', 'warehouse-overseas-service'], commit: null, }) const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) assert.equal(targets.baiyue_iam.target, '3') - assert.equal(targets.baiyue_firstleg.target, '4') - assert.equal(targets.baiyue_lastleg.target, '5') + assert.equal(targets.baiyue_firstleg.target, '6') + assert.equal(targets.baiyue_finance.target, '2') + assert.equal(targets.baiyue_lastleg.target, '7') assert.equal(targets.baiyue_lastlag_warehouse.target, '2') assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ - 'migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql', - 'migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql', 'migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', + 'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql', + 'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql', ]) }) diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index e794a23d3..cb77b3b15 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -45,7 +45,7 @@ case "$command" in printf '%s\\n' starting > "$MOCK_HEALTH_STATE" printf '%s\\n' restarting > "$MOCK_RUNTIME_STATE" else - printf '%s\\n' healthy > "$MOCK_HEALTH_STATE" + printf '%s\\n' "\${MOCK_HEALTH_SUCCESS:-healthy}" > "$MOCK_HEALTH_STATE" printf '%s\\n' running > "$MOCK_RUNTIME_STATE" fi ;; @@ -69,7 +69,7 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag }) { +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false }) { const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const mockBin = join(temporaryDirectory, 'bin') const dockerPath = join(mockBin, 'docker') @@ -82,7 +82,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting await mkdir(mockBin) await writeFile(dockerPath, mockDocker, 'utf8') await chmod(dockerPath, 0o755) - await writeFile(envFile, 'PLACEHOLDER=true\n', 'utf8') + await writeFile(envFile, ['NACOS_SERVER_ADDR=nacos:8848','NACOS_NAMESPACE=test-id','NACOS_USERNAME=fixture','NACOS_PASSWORD=fixture','REDIS_HOST=redis','REDIS_PORT=6379','REDIS_DATABASE=0','REDIS_TOKEN_KEY_PREFIX=baiyue:auth','INTERNAL_SERVICE_TOKEN=fixture','JWT_RSA_JWK_SET=fixture','JWT_CURRENT_KID=fixture','ROCKETMQ_NAME_SERVER=namesrv:9876','MQ_TOPIC_PREFIX=test-','AUDIT_DB_URL=jdbc:mysql://mysql/audit','AUDIT_DB_USERNAME=fixture','AUDIT_DB_PASSWORD=fixture',''].join('\n'), 'utf8') await writeFile(logFile, '', 'utf8') await writeFile(currentImageFile, 'registry.local/group/firstleg/audit-service:oldgood\n', 'utf8') await writeFile(healthStateFile, 'healthy\n', 'utf8') @@ -90,10 +90,10 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting const result = spawnSync(shell, [ deployScript.replaceAll('\\', '/'), - 'development', + environment, 'registry.local/group/firstleg', imageTag, - 'audit-service', + service, envFile.replaceAll('\\', '/') ], { cwd: repositoryRoot, @@ -102,10 +102,12 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting ...process.env, PATH: `${mockBin}${delimiter}${process.env.PATH ?? ''}`, TARGET_IS_LOCAL: 'true', + ALLOW_SAME_CONTRACT_ROLLBACK: String(allowRollback), REGISTRY_USER: 'ci-user', REGISTRY_PASSWORD: 'ci-password', DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', DEPLOY_HEALTH_POLL_SECONDS: '1', + MOCK_HEALTH_SUCCESS: missingHealth ? 'no-health:running' : 'healthy', MOCK_LOG: logFile, MOCK_CURRENT_IMAGE: currentImageFile, MOCK_HEALTH_STATE: healthStateFile, @@ -173,3 +175,28 @@ test('restart loops fail fast and restore the previous image', async (context) = assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') }) + +test('architecture switch failure does not roll back one old service', async () => { + const result = await runDeployment({imageTag:'newbad', failingTag:'newbad', allowRollback:false}) + assert.equal(result.status,1) + assert.doesNotMatch(result.dockerLog,/pull .*:oldgood/) + assert.match(result.stderr,/Automatic rollback disabled/) +}) +test('non TEST environments and retired services fail before Docker', async () => { + for(const options of [{environment:'production'},{environment:'development'},{service:'event-bridge-service'},{service:'gateway-overseas'}]){ + const result=await runDeployment({imageTag:'newgood',...options}) + assert.equal(result.status,2) + assert.equal(result.dockerLog,'') + } +}) + +test('running without HEALTHCHECK cannot be counted as deployed',async()=>{ + const result=await runDeployment({imageTag:'newgood',missingHealth:true,allowRollback:false}) + assert.equal(result.status,1) + assert.match(result.stderr,/Missing HEALTHCHECK/) +}) +test('SQL initializer rejects absent authorization before any Docker call',()=>{ + const result=spawnSync(shell,[resolve(repositoryRoot,'deploy/scripts/initialize-test-databases.sh').replaceAll('\\','/')],{encoding:'utf8',env:{...process.env,ALLOW_TEST_SCHEMA_INITIALIZATION:'false'}}) + assert.equal(result.status,2) + assert.match(result.stderr,/Explicit TEST SQL authorization required/) +}) diff --git a/scripts/event-outbox-operations.mjs b/scripts/event-outbox-operations.mjs new file mode 100644 index 000000000..2c64ea0ab --- /dev/null +++ b/scripts/event-outbox-operations.mjs @@ -0,0 +1,50 @@ +import { parseArgs } from 'node:util' +import { pathToFileURL } from 'node:url' + +export function buildRequest(argv, env) { + const { values: args } = parseArgs({ args: argv, options: { + help: { type: 'boolean' }, service: { type: 'string' }, base: { type: 'string' }, + entry: { type: 'string' }, retry: { type: 'boolean' }, 'request-id': { type: 'string' }, + operator: { type: 'string' }, reason: { type: 'string' }, hash: { type: 'string' }, + } }) + if (args.help) return null + if (env.CHONGCHONG_ENV !== 'test') throw new Error('CHONGCHONG_ENV must be test') + if (!['firstleg', 'lastleg', 'finance'].includes(args.service)) throw new Error('Unknown producer service') + if (!/^[A-Za-z0-9-]{1,64}$/.test(args.entry ?? '')) throw new Error('Invalid entry ID') + const base = new URL(args.base) + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || base.pathname !== '/') { + throw new Error('Base must be an explicit service origin without credentials or path') + } + const token = env.EVENT_OPERATIONS_TOKEN + if (!token || Buffer.byteLength(token) < 32) throw new Error('EVENT_OPERATIONS_TOKEN must contain at least 32 bytes') + const url = new URL(`/internal/${args.service}/event-operations/${args.entry}${args.retry ? '/retry' : ''}`, base) + const options = { method: args.retry ? 'POST' : 'GET', redirect: 'error', + headers: { 'X-Event-Operations-Token': token, 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(15000) } + if (args.retry) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(args['request-id'] ?? '')) throw new Error('A stable request UUID is required') + for (const [name, limit] of [['operator', 128], ['reason', 500]]) { + if (!args[name]?.trim() || args[name].length > limit || /[\x00-\x1f\x7f-\x9f]/.test(args[name])) throw new Error(`Invalid ${name}`) + } + if (!/^sha256:[0-9a-f]{64}$/i.test(args.hash ?? '')) throw new Error('Expected payload hash is required') + options.body = JSON.stringify({ requestId: args['request-id'], operator: args.operator, reason: args.reason, expectedPayloadHash: args.hash }) + } + return { url, options } +} + +async function main() { + const request = buildRequest(process.argv.slice(2), process.env) + if (!request) { + console.log('Inspect: --service firstleg|lastleg|finance --base --entry \nRetry: add --retry --request-id --operator --reason --hash \nEnvironment: CHONGCHONG_ENV=test, EVENT_OPERATIONS_TOKEN. No automatic retry or redirects. Retry writes Outbox state and audit atomically.') + return + } + const response = await fetch(request.url, request.options) + if (!response.ok) throw new Error(`Operations endpoint returned HTTP ${response.status}`) + const result = await response.json() + if (result?.code !== '200') throw new Error(`Operations rejected: code=${String(result?.code ?? 'missing')}`) + console.log(JSON.stringify(result.data, null, 2)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(error.message); process.exitCode = 1 }) +} diff --git a/scripts/event-outbox-operations.test.mjs b/scripts/event-outbox-operations.test.mjs new file mode 100644 index 000000000..20a1cadba --- /dev/null +++ b/scripts/event-outbox-operations.test.mjs @@ -0,0 +1,23 @@ +import { buildRequest } from './event-outbox-operations.mjs' +import test from 'node:test' +import assert from 'node:assert/strict' +const env = { CHONGCHONG_ENV: 'test', EVENT_OPERATIONS_TOKEN: 't'.repeat(40) } +const args = ['--service', 'finance', '--base', 'http://127.0.0.1:9200', '--entry', 'event-123'] +test('inspection stays on explicit internal producer origin without redirects', () => { + const request = buildRequest(args, env) + assert.equal(request.url.href, 'http://127.0.0.1:9200/internal/finance/event-operations/event-123') + assert.equal(request.options.method, 'GET') + assert.equal(request.options.redirect, 'error') + assert.equal(request.options.body, undefined) +}) +test('retry requires stable identity, reason and expected payload hash', () => { + assert.throws(() => buildRequest([...args, '--retry'], env), /UUID/) + const request = buildRequest([...args, '--retry', '--request-id', '11111111-1111-4111-8111-111111111111', '--operator', 'operator-7', '--reason', 'dependency restored', '--hash', 'sha256:' + 'a'.repeat(64)], env) + assert.equal(request.options.method, 'POST') + assert.equal(JSON.parse(request.options.body).operator, 'operator-7') +}) +test('wrong environment, weak credential and unknown service fail before HTTP', () => { + assert.throws(() => buildRequest(args, { ...env, CHONGCHONG_ENV: 'prod' }), /must be test/) + assert.throws(() => buildRequest(args, { ...env, EVENT_OPERATIONS_TOKEN: '' }), /TOKEN/) + assert.throws(() => buildRequest(['--service', 'bridge'], env), /Unknown/) +}) diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs index 17a496a53..a5664120d 100644 --- a/scripts/finance-deployment-contract.test.mjs +++ b/scripts/finance-deployment-contract.test.mjs @@ -9,17 +9,17 @@ test('Jenkins pipelines build finance as an independent service', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - assert.match(controller, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(controller, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(controller, /'finance': 'SERVICE_FINANCE'/) assert.match(controller, /booleanParam\(name: 'SERVICE_FINANCE'/) - assert.match(service, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(service, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(service, /'finance': 'finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar'/) }) test('Compose and deploy script expose finance with fail-closed database config', async () => { const compose = await read('deploy/docker-compose.yml') const serviceMap = await read('deploy/service-map.yml') - const deployScript = await read('deploy/scripts/deploy-services.sh') + const deployScript = await read('deploy/scripts/deploy-built-service.sh') assert.match(compose, /^ finance:\s*$/m) assert.match(compose, /image: \$\{IMAGE_REPOSITORY\}\/finance:\$\{IMAGE_TAG\}/) @@ -28,5 +28,5 @@ test('Compose and deploy script expose finance with fail-closed database config' assert.doesNotMatch(compose, /FINANCE_WALLET_DEFAULT_CREDIT_LIMIT/) assert.match(serviceMap, /^ finance:\s*$/m) assert.match(serviceMap, /jar: finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(deployScript, /FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD/) + assert.match(deployScript, /for suffix in URL USERNAME PASSWORD/) }) diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 31f96cf24..f421241d8 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { +test('root POM freezes 20 modules and service-map freezes 13 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 22) - assert.equal(new Set(modules).size, 22) - assert.equal(services.size, 15) + assert.equal(modules.length, 20) + assert.equal(new Set(modules).size, 20) + assert.equal(services.size, 13) const bootModules = [] for (const module of modules) { @@ -181,24 +181,13 @@ test('SpotBugs remains an opt-in non-blocking report without quality thresholds' assert.doesNotMatch(packageJob, /^ - 静态分析报告$/m) }) -test('regional Compose and Nacos manifests cover the service-map topology', async () => { +test('one TEST Compose and Nacos manifest cover every retained service', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) - const composeByRegion = new Map([ - ['CN', parseCompose(await read('deploy/docker-compose.yml'))], - ['OVERSEAS', parseCompose(await read('deploy/docker-compose.overseas.yml'))] - ]) - + const compose = parseCompose(await read('deploy/docker-compose.yml')) + assert.deepEqual(sorted(compose.keys()), sorted(services.keys())) + assert.equal(services.size, 13) for (const [module, service] of services) { - const regions = service.regions ?? ['CN'] - for (const [region, compose] of composeByRegion) { - assert.equal(compose.has(module), regions.includes(region), `${module} Compose region drifted for ${region}`) - if (!regions.includes(region)) continue - assert.equal(compose.get(module), service.port, `${module} internal port drifted for ${region}`) - await access(new URL(`../config/nacos/${region.toLowerCase()}/${service.artifactId}.yml`, import.meta.url)) - } + assert.equal(compose.get(module), service.port, module) + await access(new URL(`../config/nacos/test/${service.artifactId}.yml`, import.meta.url)) } - assert.deepEqual( - sorted(new Set([...composeByRegion.values()].flatMap((compose) => [...compose.keys()]))), - sorted(services.keys()) - ) }) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 4f294563b..e3a9c9c24 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -1,204 +1,53 @@ import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' +import { readFileSync, existsSync, readdirSync } from 'node:fs' import test from 'node:test' +const read = p => readFileSync(new URL('../' + p, import.meta.url), 'utf8').replaceAll('\r\n','\n') +const exists = p => existsSync(new URL('../' + p, import.meta.url)) -const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('local, development and test Nacos publishing remain environment-isolated', async () => { - const publisher = await read('scripts/publish-nacos-config.ps1') - const local = [ - await read('config/nacos/local/gateway-overseas.yml'), - await read('config/nacos/local/lastleg-service.yml'), - await read('config/nacos/local/warehouse-overseas-service.yml'), - ].join('\n') - const remote = [ - await read('config/nacos/overseas/gateway-overseas.yml'), - await read('config/nacos/overseas/lastleg-service.yml'), - await read('config/nacos/overseas/warehouse-overseas-service.yml'), - ].join('\n') - - assert.match(publisher, /ValidateSet\('local', 'dev', 'development', 'test', 'production'\)/) - assert.match(publisher, /必须显式指定非 public 的 Nacos namespace/) - assert.match(local, /127\.0\.0\.1:9100/) - assert.match(local, /127\.0\.0\.1:9303/) - assert.doesNotMatch(remote, /127\.0\.0\.1|password:\s*123456/) - assert.match(remote, /http:\/\/lastleg-service:9303/) - assert.match(remote, /\$\{LASTLEG_DB_URL\}/) - assert.match(remote, /\$\{WAREHOUSE_DB_URL\}/) +test('TEST has one complete Compose, namespace, session and no retired deployment entry', () => { + const compose=read('deploy/docker-compose.yml') + for(const service of ['gateway','auth','iam','firstleg','finance','order','warehouse','file','print','integration-service','lastleg-service','warehouse-overseas-service','audit-service']) assert.match(compose,new RegExp('^ '+service+':','m')) + assert.doesNotMatch(compose,/event-bridge|gateway-overseas|OVERSEAS_REDIS|OVERSEAS_NACOS|OVERSEAS_JWT|JWT_SECRET|test-cn|test-overseas/) + assert.match(compose,/SPRING_PROFILES_ACTIVE: test/) + assert.match(compose,/NACOS_NAMESPACE: \$\{NACOS_NAMESPACE:\?actual TEST namespace ID required\}/) + assert.match(compose,/REDIS_DATABASE: \$\{REDIS_DATABASE:-0\}/) + assert.match(compose,/SPRING_DATA_REDIS_USERNAME: \$\{REDIS_USERNAME:-default\}/) + assert.match(compose,/JWT_JWKS_URI: http:\/\/auth:9100\/api\/auth\/\.well-known\/jwks.json/) + assert.equal([...compose.matchAll(/^ ports:/gm)].length,1) + assert.equal([...compose.matchAll(/^ JWT_RSA_JWK_SET:/gm)].length,1) + assert.equal([...compose.matchAll(/^ EVENT_OPERATIONS_TOKEN:/gm)].length,3) + assert.match(compose,/LASTLEG_SERVICE_URL: http:\/\/lastleg-service:9303/) + assert.match(compose,/FIRSTLEG_SERVICE_URL: http:\/\/firstleg:9202/) + assert.match(compose,/FINANCE_SERVICE_URL: http:\/\/finance:9203/) + assert.match(compose,/FILE_UPLOADS_HOST_DIR/) + assert.match(compose,/ROCKETMQ_NAME_SERVER/) + for(const p of ['deploy/docker-compose.overseas.yml','deploy/scripts/deploy-blue-green-service.sh','deploy/scripts/deploy-development-service.sh']) assert.equal(exists(p),false,p) }) - -test('Jenkins pipelines recognize all overseas deployable modules', async () => { - const controller = await read('Jenkinsfile') - const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { - assert.match(controller, new RegExp(`'${module}'`)) - assert.match(service, new RegExp(`'${module}'`)) - } - assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) - assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) - assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) - assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) - assert.doesNotMatch(controller, /overseas-security-service|SERVICE_OVERSEAS_SECURITY/) - assert.doesNotMatch(service, /overseas-security-service/) - assert.match(controller, /EVENT_BRIDGE_REGION/) - assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) - assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) - assert.match(service, /choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) - assert.doesNotMatch(controller, /事件桥 BOTH 仅支持开发或测试环境/) - assert.match(service, /部署生产事件桥实例:/) +test('every retained application imports TEST configuration with explicit namespace and no SQL auto init',()=>{ + const services=[...read('deploy/service-map.yml').matchAll(/^ ([a-z][a-z-]+):/gm)].map(m=>m[1]) + for(const service of services){ + const app=read(`${service}/src/main/resources/application.yml`) + assert.match(app,/nacos:baiyue-common.yml\?group=\$\{NACOS_GROUP:BAIYUE\}/) + assert.match(app,/\$\{NACOS_NAMESPACE\}/) + assert.doesNotMatch(app,/optional:nacos|NACOS_NAMESPACE:public|mode: always|EVENT_BRIDGE|JWT_SECRET|REDIS_PORT:6380/) + } + const common=read('config/nacos/test/baiyue-common.yml') + assert.match(common,/jwt-mode: jwks/) + assert.match(common,/session-mode: redis/) + assert.match(common,/session-validation-uri: \$\{AUTH_SESSION_VALIDATION_URL:http:\/\/auth:9100\/internal\/auth\/accounts\/sessions\/validate\}/) + assert.match(read('deploy/scripts/deploy-built-service.sh'),/Missing HEALTHCHECK; deployment failed/) }) - -test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { - const map = await read('deploy/service-map.yml') - assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) - assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) - assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) - assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) - assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.doesNotMatch(map, /overseas-security-service|9307/) - assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) -}) - -test('overseas compose is isolated from CN defaults and disables implicit schema execution', async () => { - const compose = await read('deploy/docker-compose.overseas.yml') - assert.match(compose, /^name: firstleg-\$\{DEPLOY_ENV:-test\}-overseas$/m) - assert.match(compose, /NACOS_USERNAME: \$\{OVERSEAS_NACOS_USERNAME\}/) - assert.match(compose, /NACOS_PASSWORD: \$\{OVERSEAS_NACOS_PASSWORD\}/) - assert.match(compose, /NACOS_NAMESPACE: \$\{OVERSEAS_NACOS_NAMESPACE:-prod-overseas\}/) - assert.match(compose, /REGION_CODE: OVERSEAS/) - assert.match(compose, /SPRING_SQL_INIT_MODE: never/) - assert.match(compose, /^ gateway-overseas:\s*$/m) - assert.match(compose, /^ lastleg-service:\s*$/m) - assert.match(compose, /^ warehouse-overseas-service:\s*$/m) - assert.match(compose, /^ event-bridge-service:\s*$/m) - assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-\$\{EVENT_BRIDGE_OVERSEAS_PORT:-9313\}}:9312/) - assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) - assert.match(compose, /LASTLEG_DB_URL: \$\{LASTLEG_DB_URL\}/) - assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) - assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) - assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/) - assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/) - assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/) - assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FILE_SERVICE_URL: \$\{FILE_SERVICE_URL\}/) - assert.doesNotMatch(compose, /DOMESTIC_FILE_SERVICE_URL/) - assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) - assert.match(compose, /aliases: \[event-bridge-overseas\]/) -}) - -test('CN and overseas event bridges have explicit opposite-region consumers', async () => { - const cnCompose = await read('deploy/docker-compose.yml') - const overseasCompose = await read('deploy/docker-compose.overseas.yml') - const cn = await read('config/nacos/cn/event-bridge-service.yml') - const overseas = await read('config/nacos/overseas/event-bridge-service.yml') - const firstleg = await read('config/nacos/cn/firstleg-service.yml') - const iam = await read('config/nacos/cn/iam-service.yml') - const iamApplication = await read('iam/src/main/resources/application.yml') - const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java') - const lastleg = await read('config/nacos/overseas/lastleg-service.yml') - const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml') - const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` - assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) - assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) - assert.match(cnCompose, /EVENT_CONSUMER_URL: \$\{FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events\}/) - assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) - assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) - assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) - assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/) - assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) - assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) - assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/) - assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/) - assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) - assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) - assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) - assert.match(lastleg, /domestic-file:\s*\n\s*url: \$\{FILE_SERVICE_URL\}/) - assert.doesNotMatch(lastleg, /DOMESTIC_FILE_SERVICE_URL/) - assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/) - assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/) - assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/) - assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) - assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) - assert.match(scripts, /FILE_SERVICE_URL/) - assert.doesNotMatch(scripts, /DOMESTIC_FILE_SERVICE_URL/) - assert.match(scripts, /docker-compose\.overseas\.yml/) - assert.match(scripts, /REGION_CODE=CN 或 REGION_CODE=OVERSEAS/) - assert.match(scripts, /env_file_value REGION_CODE/) - assert.match(scripts, /EVENT_BRIDGE_REGION_OVERRIDE/) - assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/) - assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/) - assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/) - assert.match(scripts, /运行容器镜像与请求镜像不一致/) - assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) - assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) - for (const key of [ - 'NACOS_SERVER_ADDR', 'NACOS_USERNAME', 'NACOS_PASSWORD', 'OVERSEAS_NACOS_NAMESPACE', 'INTERNAL_SERVICE_TOKEN', - 'JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', 'JWT_JWK_SET_URI' - ]) assert.match(scripts, new RegExp(key)) -}) - -test('domestic Auth receives the overseas JWT signing configuration', async () => { - const compose = await read('deploy/docker-compose.yml') - for (const key of [ - 'OVERSEAS_JWT_ENABLED', 'OVERSEAS_JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', - 'OVERSEAS_JWT_RSA_JWK_SET', 'OVERSEAS_JWT_CURRENT_KID', 'OVERSEAS_ACCESS_TOKEN_TTL' - ]) assert.match(compose, new RegExp(`${key}: \\$\\{${key}`)) -}) - -test('domestic event bridge initialization defines customer columns without legacy migration', async () => { - const baseline = await read('event-bridge-service/src/main/resources/db/event-bridge.sql') - const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') - assert.equal((baseline.match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) - for (const table of ['bridge_inbox', 'bridge_outbox']) { - const definition = incremental.match(new RegExp(`CREATE TABLE ${table} \\(([\\s\\S]*?)\\) ENGINE`))?.[1] - assert.ok(definition, `${table} must be defined by the empty-database baseline`) - assert.match(definition, /customer_id varchar\(128\)[^\n]*NOT NULL/i) - assert.doesNotMatch(definition, /\btenant_id\b/) - } - assert.doesNotMatch(incremental, /RENAME COLUMN tenant_id TO customer_id/) -}) - -test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => { - const compose = await read('deploy/docker-compose.overseas.yml') - for (const service of ['lastleg-service', 'warehouse-overseas-service']) { - const block = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|^networks:)`, 'm'))?.[1] - assert.ok(block, `${service} must have a deployment definition`) - assert.match(block, /PRINT_SERVICE_URL: \$\{PRINT_SERVICE_URL:-http:\/\/print:9207\}/, - `${service} cannot discover the CN Print service from its overseas Nacos namespace`) - const config = await read(`${service}/src/main/resources/application.yml`) - assert.match(config, /print:\s*\n\s+url: \$\{PRINT_SERVICE_URL:\}/) - } -}) - -test('domestic initialization defines the overseas hold version and comment in the table', async () => { - const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') - const definition = incremental.match(/CREATE TABLE fl_inbound_order \(([\s\S]*?)\) ENGINE/)?.[1] - assert.ok(definition) - assert.match(definition, /last_overseas_hold_version bigint unsigned DEFAULT NULL COMMENT '[^']+'/i) - assert.doesNotMatch(incremental, /CALL (?:add_inbound_overseas_hold_version|queue_column_comment)\(/) -}) - -test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => { - const overseas = await read('deploy/sql/海外区域统一增量-MySQL8.sql') - assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/) - assert.match(overseas, /USE baiyue_event_bridge_overseas;/) - for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) { - assert.match(overseas, new RegExp(`CREATE TABLE ${table} \\(`)) - } - for (const index of [ - 'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule', - 'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume', - 'uk_bridge_attempt', 'idx_bridge_attempt_event' - ]) assert.match(overseas, new RegExp(index)) - assert.doesNotMatch(overseas, /CREATE TABLE IF NOT EXISTS/) - assert.match(overseas, /initialization_status/) - assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'), - 'overseas Event Bridge schema must be initialized before Lastleg tables') +test('Jenkins TEST publication and Nacos import have no dual-region or production execution',()=>{ + for(const p of ['Jenkinsfile','Jenkinsfile.service']){ + const s=read(p) + assert.doesNotMatch(s,/EVENT_BRIDGE|gateway-overseas|event-bridge-service|return 'production'|return 'development'|firstleg-production-env|java-dev/) + assert.doesNotMatch(s,/maven.test.skip/) + } + assert.match(read('Jenkinsfile.service'),/node\('java-test'\)/) + assert.match(read('Jenkinsfile.service'),/ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false/) + const publisher=read('scripts/publish-nacos-config.ps1') + assert.match(publisher,/ValidateSet\('test'\)/) + assert.match(publisher,/config\/nacos\/test/) + assert.match(publisher,/\[switch\]\$DryRun/) + assert.match(publisher,/accessToken/) }) diff --git a/scripts/publish-local-nacos-config.ps1 b/scripts/publish-local-nacos-config.ps1 deleted file mode 100644 index 7f249c4ce..000000000 --- a/scripts/publish-local-nacos-config.ps1 +++ /dev/null @@ -1,49 +0,0 @@ -param( - [string]$ServerAddr = '127.0.0.1:8848', - [string]$Group = 'BAIYUE', - [string]$Namespace = 'public' -) - -$ErrorActionPreference = 'Stop' -$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -$configRoot = Join-Path $projectRoot 'config\nacos\local' -$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" -$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" - -$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 -if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { - throw "Nacos not ready: $healthUrl" -} - -$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name -if ($files.Count -eq 0) { - throw "No local Nacos configuration found: $configRoot" -} - -$published = 0 -foreach ($file in $files) { - $body = @{ - dataId = $file.Name - group = $Group - content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 - type = 'yaml' - } - if ($Namespace -and $Namespace -ne 'public') { - $body.tenant = $Namespace - } - - $pairs = foreach ($entry in $body.GetEnumerator()) { - [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) - } - $form = $pairs -join '&' - $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` - -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` - -Body $form -TimeoutSec 10 - if ([string]$result -ne 'true') { - throw "Publish failed: $($file.Name), Nacos response: $result" - } - $published++ - Write-Host "Published $($file.Name) -> $Namespace / $Group" -} - -Write-Host "Published local Nacos configuration: $published items." diff --git a/scripts/publish-nacos-config.ps1 b/scripts/publish-nacos-config.ps1 index 0231e4130..6005bb2e6 100644 --- a/scripts/publish-nacos-config.ps1 +++ b/scripts/publish-nacos-config.ps1 @@ -1,68 +1,27 @@ param( - [ValidateSet('local', 'dev', 'development', 'test', 'production')] - [string]$Environment, - [ValidateSet('cn', 'overseas')] - [string]$Region = 'cn', - [string]$ServerAddr = '127.0.0.1:8848', + [ValidateSet('test')][string]$Environment = 'test', + [Parameter(Mandatory)][string]$ServerAddr, [string]$Group = 'BAIYUE', - [string]$Namespace + [Parameter(Mandatory)][string]$Namespace, + [string]$Username = $env:NACOS_USERNAME, + [string]$Password = $env:NACOS_PASSWORD, + [switch]$DryRun ) - $ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($Namespace) -or $Namespace -eq 'public') { throw 'Supply the actual TEST Namespace ID, not public or a display name' } $projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) - -if ($Environment -eq 'local') { - if ($Namespace -and $Namespace -ne 'public') { - throw 'local 环境只能发布到 public namespace' - } - $Namespace = 'public' - $configProfile = 'local' -} else { - if (-not $Namespace -or $Namespace -eq 'public') { - throw "$Environment/$Region 必须显式指定非 public 的 Nacos namespace" - } - $configProfile = $Region -} - -$configRoot = (Resolve-Path (Join-Path $projectRoot "config\nacos\$configProfile")).Path -$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" -$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" -$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 -if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { - throw "Nacos not ready: $healthUrl" -} - -$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name -if ($files.Count -eq 0) { - throw "No Nacos configuration found: $configRoot" -} - -$published = 0 +$configRoot = Join-Path $projectRoot 'config/nacos/test' +$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object @{Expression={if ($_.Name -eq 'baiyue-common.yml') {0} else {1}}}, Name +if ($files.Count -ne 14) { throw 'Expected common configuration and 13 retained services' } +if ($DryRun) { $files | ForEach-Object { Write-Host "Would publish $($_.Name) -> $Namespace / $Group" }; return } +if (-not $Username -or -not $Password) { throw 'NACOS_USERNAME and NACOS_PASSWORD are required' } +$base = "http://$ServerAddr/nacos" +$login = Invoke-RestMethod -Method Post -Uri "$base/v1/auth/login" -Body @{username=$Username; password=$Password} -TimeoutSec 10 +if (-not $login.accessToken) { throw 'Nacos authentication failed' } foreach ($file in $files) { - $resolvedFile = (Resolve-Path -LiteralPath $file.FullName).Path - if (-not $resolvedFile.StartsWith($configRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Unexpected config path: $resolvedFile" - } - $body = @{ - dataId = $file.Name - group = $Group - content = Get-Content -LiteralPath $resolvedFile -Raw -Encoding UTF8 - type = 'yaml' - } - if ($Namespace -ne 'public') { - $body.tenant = $Namespace - } - $pairs = foreach ($entry in $body.GetEnumerator()) { - [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) - } - $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` - -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` - -Body ($pairs -join '&') -TimeoutSec 10 - if ([string]$result -ne 'true') { - throw "Publish failed: $($file.Name), Nacos response: $result" - } - $published++ + $body = @{dataId=$file.Name; group=$Group; tenant=$Namespace; type='yaml'; content=(Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8); accessToken=$login.accessToken} + $pairs = foreach ($entry in $body.GetEnumerator()) { [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) } + $result = Invoke-RestMethod -Method Post -Uri "$base/v1/cs/configs" -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' -Body ($pairs -join '&') -TimeoutSec 10 + if ([string]$result -ne 'true') { throw "Publish failed: $($file.Name)" } Write-Host "Published $($file.Name) -> $Namespace / $Group" } - -Write-Host "Published $Environment/$Region Nacos configuration: $published items." diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 977e42208..9c527c0ea 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,16 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java', + 'lastleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用及资金指令按eventId/hash直投对应Finance内部入口,严格DeliveryAck'), + http('common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', + 'common-security', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'Servlet认证读取权威会话状态;内部令牌、总超时、拒绝重定向、失败关闭'), + http('gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java', + 'gateway', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', '统一入口非阻塞查询权威会话;内部令牌与超时,失败关闭'), + http('firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java', + 'firstleg-service', 'external-lingxing-browser', ['EXTERNAL'], 'READ_WRITE', 'MIXED', 'CDP读取浏览器会话及执行登录/验证码;人工验证不可自动绕过'), + http('firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java', + 'firstleg-service', 'external-lingxing', ['EXTERNAL'], 'READ_WRITE', 'MIXED', '领星SKU查询与新品维护写入;已维护状态防止重复提交,不假定外部幂等'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/DomesticPdaOperatorClient.java', 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '按作业仓与账号查询有效国内 PDA 操作人,内部服务令牌保护'), feign('order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java', @@ -54,15 +64,15 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), - feign('finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java', - 'finance-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), + feign('finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java', + 'finance-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), - feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', - 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java', + 'firstleg-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java', @@ -118,11 +128,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'IAM 持久化任务租约投递;初始化/补偿复用稳定 initializationKey,不在客户创建请求内调用'), feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), - feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', - 'iam-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全 Outbox 按 eventId 投递'), - feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java', - 'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java', + 'lastleg-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java', 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取国内共享仓库权威目录'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', @@ -159,18 +167,6 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java', - 'event-bridge-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '跨区费用 Inbox 按 eventId 投递 Finance'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', - 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', - 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', - 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), - http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', - 'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), - http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'), http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index d94f07c7c..d4d269793 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -10,13 +10,11 @@ import { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const serviceModules = [ - 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', - 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service' -] +// Discover all active Maven modules; unreviewed clients must fail equality checks. +const serviceModules = [...(await readFile(path.join(root, 'pom.xml'), 'utf8')) + .matchAll(/([^<]+)<\/module>/g)].map(match => match[1]) -const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) +const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') || name.startsWith('common-') || name === 'gateway' || name === 'platform-bom' ? '' : '-service'}`])) const expectedOnlineEdges = [ 'auth-service->iam-service', @@ -47,25 +45,22 @@ const expectedOnlineEdges = [ ] const expectedBackgroundEdges = [ - 'event-bridge-service->finance-service', - 'event-bridge-service->firstleg-service', - 'event-bridge-service->lastleg-service', 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', 'file-service->order-service', 'file-service->warehouse-service', - 'finance-service->event-bridge-service', + 'finance-service->lastleg-service', 'finance-service->firstleg-service', - 'firstleg-service->event-bridge-service', + 'firstleg-service->lastleg-service', 'firstleg-service->finance-service', 'firstleg-service->integration-service', 'firstleg-service->order-service', - 'iam-service->event-bridge-service', 'iam-service->firstleg-service', - 'lastleg-service->event-bridge-service', + 'lastleg-service->finance-service', + 'lastleg-service->firstleg-service', 'order-service->firstleg-service' -] +].sort() const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ @@ -78,7 +73,7 @@ async function javaFiles(directory) { else if (entry.isFile() && entry.name.endsWith('.java')) result.push(target) } } - await visit(directory) + if (await readdir(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error })) await visit(directory) return result } @@ -111,17 +106,17 @@ test('all Feign clients have reviewed context, access and idempotency metadata', test('all raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] - for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { + for (const module of serviceModules) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { const source = await readFile(file, 'utf8') if (!rawHttpPattern.test(source)) continue - if (!/(?:private|final)\s+(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue + if (!/(?:private|final)\s+(?:[\w.]+\.)?(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue discovered.push(path.relative(root, file).replaceAll('\\', '/')) } } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 24) + assert.equal(reviewed.length, 22) assert.deepEqual(discovered.sort(), reviewed) }) @@ -175,7 +170,7 @@ test('all six Lastleg RestClient gateways use explicit bounded transports', asyn for (const configFile of [ 'lastleg-service/src/main/resources/application.yml', - 'config/nacos/overseas/lastleg-service.yml' + 'config/nacos/test/lastleg-service.yml' ]) { const source = await readFile(path.join(root, configFile), 'utf8') for (const key of ['unload-photo-file', 'relabel-photo-file', 'customer-visit-file', 'procurement-file', 'warehouse-resource', 'pallet-physical']) { @@ -191,26 +186,28 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ 'common-audit->audit-service', - 'gateway-overseas->auth-service', - 'gateway-overseas->lastleg-service' + 'common-security->auth-service', + 'gateway->auth-service' ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ ['auth-service', 'iam-service'], ['finance-service', 'firstleg-service', 'order-service'] ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ - 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' + 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' ]]) assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ - 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', + 'auth-service', 'file-service', 'finance-service', 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' ]]) }) -test('overseas security projection stays in Lastleg infrastructure and background edges', () => { - assert.ok(!graphEdges('REQUEST').some(edge => edge === 'gateway-overseas->lastleg-service')) - assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->lastleg-service')) - assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) +test('unified authority checks remain infrastructure edges and direct events remain background', () => { + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway->auth-service')) + assert.ok(graphEdges('INFRASTRUCTURE').includes('common-security->auth-service')) + assert.ok(graphEdges('BACKGROUND').includes('lastleg-service->finance-service')) + assert.ok(!graphEdges('REQUEST').includes('lastleg-service->finance-service')) + assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) }) test('Order, Firstleg and Finance form the reviewed REQUEST SCC', () => { @@ -284,7 +281,7 @@ test('known user entries expose every longest simple REQUEST path over the two-h }) test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { - for (const profile of ['local', 'cn', 'overseas']) { + for (const profile of ['test']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') assert.match(source, /connect-timeout:\s*3000/) assert.match(source, /read-timeout:\s*5000/) diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 606d5925c..a23e788fb 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -14,34 +14,24 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', .replaceAll('\r\n', '\n') const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') -const overseasCompose = (await readFile(new URL('../deploy/docker-compose.overseas.yml', import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) }) -test('test deployments compile packages without rerunning the full unit test suite', () => { - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) +test('test compilation is preserved even when execution is explicitly skipped', () => { + assert.match(pipeline, /boolean skipTestExecution = params\.SKIP_TESTS/) assert.match(pipeline, /skipTestExecution \? '-DskipTests' : ''/) assert.match(pipeline, /-pl \$\{env\.RESOLVED_SERVICE\} -am clean package/) assert.doesNotMatch(pipeline, /-Dmaven\.test\.skip=true/) }) test('coordinator and service jobs enforce their own deployment environments', () => { - assert.match(coordinatorPipeline, /case 'backend-java-dev':[\s\S]*?return 'development'/) - assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) - assert.match(coordinatorPipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/) - assert.match(coordinatorPipeline, /environmentChoicesForJob\(env\.JOB_BASE_NAME\)/) - assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) - assert.match(coordinatorPipeline, /env\.RESOLVED_JOB_PREFIX = resolvedJobPrefix/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-dev-'\)[\s\S]*?return 'development'/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-prod-'\)[\s\S]*?return 'production'/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-'\)[\s\S]*?return 'test'/) - assert.match(pipeline, /environmentChoicesForServiceJob\(env\.JOB_BASE_NAME\)/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) - assert.doesNotMatch(pipeline, /params\.ENVIRONMENT ==/) - assert.doesNotMatch(pipeline, /contains\(params\.ENVIRONMENT\)/) + assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) + assert.doesNotMatch(coordinatorPipeline,/return 'production'|return 'development'/) + assert.match(pipeline,/startsWith\('baiyue-firstleg-'\)/) + assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-dev-'\)/) + assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-prod-'\)/) }) test('AUTO advances interrupted baselines and retries only their selected services', () => { @@ -72,67 +62,35 @@ test('AUTO maps shared modules to their real consumers instead of rebuilding eve assert.doesNotMatch(securityImpact, /gateway|print|integration-service|event-bridge-service/) }) -test('production deployments cannot skip tests', () => { - assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) - assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) - assert.match(pipeline, /生产环境手工构建禁止跳过测试/) - assert.match(coordinatorPipeline, /refs\/heads\/main:refs\/remotes\/origin\/main/) - assert.match(coordinatorPipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/) - assert.match(pipeline, /git merge-base --is-ancestor "\$\{RESOLVED_SOURCE_COMMIT\}" origin\/main/) +test('TEST builds respect explicit test execution controls', () => { + assert.doesNotMatch(coordinatorPipeline,/backend-java-prod|return 'production'/) + assert.match(coordinatorPipeline,/!params.SKIP_TESTS/) + assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/) }) test('Java builds use the shared builder pool and serialize incremental target deployments', () => { - assert.match(pipeline, /agent \{ label 'java-builder' \}/) - assert.match(pipeline, /\['development', 'test'\]\.contains\(env\.RESOLVED_ENVIRONMENT\)/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) - assert.match(pipeline, /'TARGET_IS_LOCAL=true'/) - assert.match(pipeline, /String deploySlotLabel = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-deploy-slot"/) - assert.match(pipeline, /lock\(label: deploySlotLabel, quantity: 1, inversePrecedence: true\)/) - assert.match(pipeline, /deploy-built-service\.sh/) - assert.doesNotMatch(pipeline, /dev_deploy|test_deploy/) - assert.match(pipeline, /firstleg-production-ssh/) - assert.match(pipeline, /TARGET_SSH_KEY/) - assert.match(directDeployScript, /docker pull "\$IMAGE"/) - assert.match(directDeployScript, /run_ssh docker pull "\$IMAGE"/) - assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) - assert.match(directDeployScript, /up -d --no-deps --pull never/) - assert.doesNotMatch(directDeployScript, /--remove-orphans/) - assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/) - assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/) - assert.match(directDeployScript, /--pull never/) + assert.match(pipeline,/agent \{ label 'java-builder' \}/) + assert.match(pipeline,/node\('java-test'\)/) + assert.match(pipeline,/lock\(resource: "firstleg-test-\$\{env.RESOLVED_SERVICE\}-deploy"/) + assert.match(pipeline,/'TARGET_IS_LOCAL=true'/) + assert.match(directDeployScript,/up -d --no-deps --pull never/) + assert.doesNotMatch(directDeployScript,/--remove-orphans|run_ssh|docker save/) }) -test('production only accepts skipped downstream tests for a coordinator-pinned commit', () => { - assert.match(pipeline, /boolean coordinatorPinnedCommit = params\.SOURCE_COMMIT\?\.trim\(\) as boolean/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) - assert.match(pipeline, /生产环境手工构建禁止跳过测试/) +test('downstream skips tests only by explicit flag and keeps test compilation',()=>{ + assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/) + assert.doesNotMatch(pipeline,/maven.test.skip/) }) -test('development and test deployment locks are acquired before target executors', () => { - const directDeployStage = pipeline.slice( - pipeline.indexOf("stage('部署开发或测试环境')"), - pipeline.indexOf("stage('部署单个服务')") - ) - const serviceLockIndex = directDeployStage.indexOf('lock(resource: serviceDeployResource') - const lockIndex = directDeployStage.indexOf('lock(label: deploySlotLabel') - const nodeIndex = directDeployStage.indexOf('node(targetLabel)') - - assert.match(directDeployStage, /agent none/) - assert.match(directDeployStage, /String targetLabel = env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) - assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) - assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') - assert.ok(lockIndex >= 0, 'deployment slot lock must exist') - assert.ok(nodeIndex >= 0, 'target node allocation must exist') - assert.ok(serviceLockIndex < lockIndex, 'per-service lock must be acquired before the shared deployment slot') - assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') +test('TEST deployment lock is acquired before target executor',()=>{ + const stage=pipeline.slice(pipeline.indexOf("stage('部署 TEST')")) + assert.match(stage,/agent none/) + assert.ok(stage.indexOf('lock(resource:') < stage.indexOf("node('java-test')")) }) -test('development publishes every service on loopback without changing test defaults', () => { - assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PUBLISHED_PORT:-\$\{GATEWAY_PORT:-9000\}\}:9000/) - assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PUBLISHED_PORT:-\$\{GATEWAY_OVERSEAS_PORT:-9300\}\}:9300/) - assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then\s+PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.match(directDeployScript, /if \[ "\$deploy_env" = "development" \]; then\s+export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) +test('only Gateway publishes to host loopback',()=>{ + assert.equal([...serviceCompose.matchAll(/^ ports:/gm)].length,1) + assert.match(directDeployScript,/export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) }) test('Firstleg deployment passes terminal drayage SMTP settings into the container', () => { @@ -158,7 +116,7 @@ test('Firstleg deployment passes terminal drayage SMTP settings into the contain test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"), - pipeline.indexOf("stage('部署开发或测试环境')") + pipeline.indexOf("stage('部署 TEST')") ) const loginIndex = imageStage.indexOf('docker login') const buildIndex = imageStage.indexOf('docker build') @@ -175,25 +133,11 @@ test('parallel image publications isolate registry credentials per Jenkins job', assert.doesNotMatch(imageStage, /\/home\/deploy\/\.docker\/config\.json/) }) -test('parallel remote deployments keep env and compose assets isolated by project', () => { - assert.match(directDeployScript, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) - assert.match(directDeployScript, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) - assert.match(directDeployScript, /REMOTE_ENV_TMP="\$REMOTE_DIR\/\$REMOTE_ENV_NAME\.\$SUFFIX\.tmp"/) - assert.match(directDeployScript, /REMOTE_COMPOSE_TMP="\$REMOTE_DIR\/\$REMOTE_COMPOSE_NAME\.\$SUFFIX\.tmp"/) - assert.match(directDeployScript, /env_target="\$4"/) - assert.match(directDeployScript, /compose_target="\$5"/) - assert.match(directDeployScript, /blue_green_target="\$6"/) - assert.match(directDeployScript, /mv -f "\$env_tmp" "\$env_target"/) - assert.match(directDeployScript, /mv -f "\$compose_tmp" "\$compose_target"/) - assert.match(directDeployScript, /mv -f "\$blue_green_tmp" "\$blue_green_target"/) - assert.match(directDeployScript, /env_file="\$7"/) - assert.match(directDeployScript, /compose_file="\$8"/) - assert.match(directDeployScript, /--env-file "\$env_file"/) - assert.match(directDeployScript, /-f "\$compose_file"/) - assert.doesNotMatch(directDeployScript, /mv -f "\$env_tmp" "\$remote_dir\/\.env"/) - assert.doesNotMatch(directDeployScript, /mv -f "\$compose_tmp" "\$remote_dir\/docker-compose\.yml"/) - assert.doesNotMatch(directDeployScript, /--env-file \.env/) - assert.doesNotMatch(directDeployScript, /-f docker-compose\.yml/) +test('TEST deployments use explicit local env and Compose files', () => { + assert.match(directDeployScript,/TEST deploy must run on the target host/) + assert.match(directDeployScript,/--env-file "\$ENV_FILE"/) + assert.match(directDeployScript,/-f "\$COMPOSE_FILE"/) + assert.doesNotMatch(directDeployScript,/scp|run_ssh/) }) test('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => { @@ -202,8 +146,8 @@ test('shared builders use a reachable public Maven mirror instead of the retired assert.doesNotMatch(ciMavenSettings, /:18081/) }) -test('Java service images use the canonical Gitea registry base image by default', () => { - const internalBaseImage = 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine' +test('Java service images use configurable Java 17 base images', () => { + const internalBaseImage = 'eclipse-temurin:17-jre-alpine' assert.match(coordinatorPipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) assert.match(pipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) @@ -214,20 +158,10 @@ test('Java service images use the canonical Gitea registry base image by default }) test('source workspaces are preserved for incremental Git fetches', () => { - const buildStage = pipeline.slice( - pipeline.indexOf("stage('构建并发布单服务')"), - pipeline.indexOf("stage('部署开发或测试环境')") - ) - const directDeployStage = pipeline.slice( - pipeline.indexOf("stage('部署开发或测试环境')"), - pipeline.indexOf("stage('部署单个服务')") - ) - - assert.match(pipeline, /checkout scm/) - assert.doesNotMatch(buildStage, /cleanWs\(/) - assert.match(directDeployStage, /cleanWs\(/) - assert.equal((pipeline.match(/cleanWs\(/g) ?? []).length, 2) - assert.doesNotMatch(coordinatorPipeline, /cleanWs\(/) + const buildStage=pipeline.slice(0,pipeline.indexOf("stage('部署 TEST')")) + assert.match(buildStage,/checkout scm/) + assert.doesNotMatch(buildStage,/cleanWs\(|deleteDir\(/) + assert.match(pipeline.slice(pipeline.indexOf("stage('部署 TEST')")),/deleteDir\(\)/) }) test('direct deployments wait for health and automatically restore the previous image', () => { @@ -240,15 +174,7 @@ test('direct deployments wait for health and automatically restore the previous assert.match(directDeployScript, /return 1/) }) -test('one event bridge build deploys both regional instances in every environment', () => { - assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) - assert.match(pipeline, /\['BOTH', 'SELECT'\]\.contains\(params\.EVENT_BRIDGE_REGION\)/) - assert.match(pipeline, /\? \['CN', 'OVERSEAS'\]/) - assert.match(pipeline, /bridgeRegions\.each \{ bridgeRegion ->/) - assert.match(pipeline, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) - assert.match(pipeline, /部署事件桥实例:/) - assert.match(pipeline, /imageTag\?\.endsWith\('-both'\)/) - assert.match(pipeline, /imageTag\.substring\(0, imageTag\.length\(\) - '-both'\.length\(\)\)/) - assert.match(pipeline, /env\.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges \? 'true' : 'false'/) - assert.match(pipeline, /部署生产事件桥实例:/) +test('retired bridge jobs cannot be dispatched', () => { + assert.doesNotMatch(pipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) + assert.doesNotMatch(coordinatorPipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) }) diff --git a/scripts/use-nacos-environment.ps1 b/scripts/use-nacos-environment.ps1 index ee5f64734..0e4cdb9d4 100644 --- a/scripts/use-nacos-environment.ps1 +++ b/scripts/use-nacos-environment.ps1 @@ -1,74 +1,17 @@ param( - [ValidateSet('local', 'dev', 'development', 'test')] - [string]$Environment, - [ValidateSet('cn', 'overseas')] - [string]$Region = 'cn', - [string]$NacosServerAddr, - [string]$NacosNamespace, - [string]$NacosUsername, - [string]$NacosPassword + [ValidateSet('test')][string]$Environment = 'test', + [Parameter(Mandatory)][string]$NacosServerAddr, + [Parameter(Mandatory)][string]$NacosNamespace, + [string]$NacosUsername = $env:NACOS_USERNAME, + [string]$NacosPassword = $env:NACOS_PASSWORD ) - $ErrorActionPreference = 'Stop' -$target = if ($Environment -eq 'development') { 'dev' } else { $Environment } - -$defaults = @{ - local = @{ Server = '127.0.0.1:8848'; CnNamespace = 'public' } - dev = @{ Server = '8.163.49.32:8848'; CnNamespace = $env:DEV_NACOS_NAMESPACE } - test = @{ Server = '8.163.30.128:8848'; CnNamespace = 'd99d7998-5bb0-4ffe-a2a0-1a564ce14463' } -} -$selected = $defaults[$target] -$serverAddr = if ($NacosServerAddr) { $NacosServerAddr } else { $selected.Server } -$namespace = if ($NacosNamespace) { - $NacosNamespace -} elseif ($target -eq 'local') { - 'public' -} elseif ($Region -eq 'overseas') { - $regionNamespace = [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_OVERSEAS_NACOS_NAMESPACE") - if ($regionNamespace) { - $regionNamespace - } else { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_NAMESPACE") - } -} else { - $selected.CnNamespace -} -$username = if ($NacosUsername) { - $NacosUsername -} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME")) { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME") -} else { - 'nacos' -} -$password = if ($NacosPassword) { - $NacosPassword -} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD")) { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD") -} elseif ($target -eq 'local') { - 'nacos' -} else { - $null -} - -if (-not $namespace) { - throw "$target/$Region 必须通过 -NacosNamespace 或对应的 *_NACOS_NAMESPACE 环境变量指定 namespace" -} -if (-not $password) { - throw "$target 必须通过 -NacosPassword 或 $($target.ToUpperInvariant())_NACOS_PASSWORD 提供 Nacos 密码" -} - -$env:NACOS_SERVER_ADDR = $serverAddr -$env:NACOS_NAMESPACE = $namespace -$env:NACOS_GROUP = 'BAIYUE' -$env:NACOS_USERNAME = $username -$env:NACOS_PASSWORD = $password -$env:SPRING_CLOUD_NACOS_CONFIG_NAMESPACE = $namespace -$env:SPRING_CLOUD_NACOS_DISCOVERY_NAMESPACE = $namespace -$env:SPRING_PROFILES_ACTIVE = if ($Region -eq 'overseas') { 'overseas' } elseif ($target -eq 'local') { 'dev' } else { 'cn' } -$env:BAIYUE_RUNTIME_ENVIRONMENT = $target -$env:BAIYUE_RUNTIME_REGION = $Region - -Write-Host "Runtime environment: $target / $Region" -Write-Host "Nacos: $serverAddr; namespace: $namespace; group: BAIYUE" -Write-Host "Spring profiles: $env:SPRING_PROFILES_ACTIVE" -Write-Host 'Nacos password is loaded into the current process and is not printed.' +if (-not $NacosNamespace -or $NacosNamespace -eq 'public') { throw 'Actual TEST Namespace ID required' } +if (-not $NacosUsername -or -not $NacosPassword) { throw 'Nacos credentials required' } +$env:NACOS_SERVER_ADDR=$NacosServerAddr +$env:NACOS_NAMESPACE=$NacosNamespace +$env:NACOS_GROUP='BAIYUE' +$env:NACOS_USERNAME=$NacosUsername +$env:NACOS_PASSWORD=$NacosPassword +$env:SPRING_PROFILES_ACTIVE='test' +Write-Host 'TEST Nacos configuration loaded into this process; no configuration published.' diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 55adcf0ae..5b5ae5124 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -17,7 +17,7 @@ | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | -| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`;其他托盘调用 `/internal/print/v1/pdfs/pallet-a4-labels`、`PALLET_A4_LANDSCAPE_V1`。两者均为 297×210 mm A4 横向,托盘完整编码作为 Code 128 与底部文字,托盘标不得回退到 100×100 mm 箱唛模板。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`;其他托盘调用 `/internal/print/v1/pdfs/pallet-a4-labels`、`PALLET_A4_LANDSCAPE_V1`。两者均为 297×210 mm A4 横向,托盘完整编码作为 Code 128 与底部文字,托盘标不得回退到 100×100 mm 箱唛模板。模板只在 Print 维护;沿用托盘列表权限与统一 Gateway 的 `overseas-warehouse-resources/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,单机 Compose 使用内部 Print 地址,所有服务位于同一测试 Nacos Namespace;Feign 及熔断包装异常统一返回 `S0503` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/warehouse/` | diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 077cf099c..fe31d6f99 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -4,38 +4,38 @@ server: spring: sql: init: - mode: always + mode: never encoding: UTF-8 schema-locations: classpath:db/warehouse.sql data: redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} application: name: warehouse-overseas-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - namespace: ${NACOS_NAMESPACE:public} + server-addr: ${NACOS_SERVER_ADDR} + namespace: ${NACOS_NAMESPACE} datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} + url: ${WAREHOUSE_DB_URL} + username: ${WAREHOUSE_DB_USERNAME} + password: ${WAREHOUSE_DB_PASSWORD} management.endpoints.web.exposure.include: health,info,prometheus baiyue: @@ -48,10 +48,10 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} internal-service: - token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + token: ${INTERNAL_SERVICE_TOKEN} security: jwt-mode: jwks session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 185d89f5b..98dd5d0fd 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -16,6 +16,7 @@ | FBA 目的仓公共主数据与 Admin 候选 | 权威表 `wh_warehouse` 的 `tenant_id=0 + warehouse_type=FBA` 记录;Admin 直接调用 `GET /api/platform/v1/warehouse-admin?warehouseType=FBA&status=ACTIVE&page=1&size=200`,权限 `platform:warehouse:list`,DTO `WarehouseSummaryResponse` 的 `id/warehouseCode/warehouseName/status/countryCode/primaryAddress.stateProvince` 分别作为 ID/编码/名称/状态/国家/区域 | | FBA 目的仓内部校验 | `InternalWarehouseController.java` 的 `GET /internal/warehouses/0/{warehouseId}/active`,使用内部服务令牌返回 `WarehouseDetailResponse`;调用方仍须校验 `warehouseType=FBA` 及 ID/编码/名称一致,不得把 `OVERSEAS` 实际收货仓或 `DOMESTIC` 中转仓当作 FBA 目的仓 | | 公共海外收货仓内部目录 | `controller/InternalWarehouseController.java` 的 `/internal/warehouses/shared-overseas`、`/shared-overseas/{warehouseId}`,响应 `dto/SharedOverseasWarehouseResponse.java`;只返回平台公共 `OVERSEAS + ACTIVE + RECEIVING` 仓,供海外资源服务按 warehouseId 引用 | +| 公共海外收货仓平台候选 | `controller/PlatformOverseasReceivingOptionsController.java`;统一 Gateway 的 `/api/platform/v1/warehouse-admin/overseas-receiving-options`,权限 `platform:overseas-warehouse:warehouse:list`,平台主体且公共作用域,固定 `OVERSEAS + ACTIVE + RECEIVING`;分页上限200,契约见 `src/main/resources/openapi/warehouse-v1.yaml` | | 海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 | | 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` | | 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` | diff --git a/warehouse/src/main/resources/application-dev.yml b/warehouse/src/main/resources/application-dev.yml deleted file mode 100644 index 73de396cf..000000000 --- a/warehouse/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} diff --git a/warehouse/src/main/resources/application-test.yml b/warehouse/src/main/resources/application-test.yml deleted file mode 100644 index 5fb37b7f4..000000000 --- a/warehouse/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${WAREHOUSE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${WAREHOUSE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml index 55de853bf..dd13b0081 100644 --- a/warehouse/src/main/resources/application.yml +++ b/warehouse/src/main/resources/application.yml @@ -2,28 +2,27 @@ server: port: ${WAREHOUSE_PORT:9205} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: warehouse-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/warehouse/src/main/resources/openapi/warehouse-v1.yaml b/warehouse/src/main/resources/openapi/warehouse-v1.yaml index 909774b5e..75d263d1b 100644 --- a/warehouse/src/main/resources/openapi/warehouse-v1.yaml +++ b/warehouse/src/main/resources/openapi/warehouse-v1.yaml @@ -1,8 +1,46 @@ openapi: 3.0.3 info: - title: Warehouse internal order catalog + title: Warehouse catalog and receiving options version: 1.0.0 paths: + /api/platform/v1/warehouse-admin/overseas-receiving-options: + get: + summary: 分页查询启用的公共海外收货仓 + description: 仅允许平台主体(appCode=PLATFORM、tenantId=0)且具有 platform:overseas-warehouse:warehouse:list 权限。固定筛选公共 OVERSEAS、ACTIVE、RECEIVING 仓,不授予仓库主档维护权限。统一 Gateway 入口,统一 Access Token。 + security: + - bearerAuth: [] + parameters: + - name: page + in: query + schema: {type: integer, format: int64, minimum: 1, default: 1} + - name: size + in: query + schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 200} + responses: + '200': + description: 统一响应;code字符串200为成功,业务拒绝返回非200字符串code。Long字段序列化为十进制字符串。 + content: + application/json: + schema: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + nullable: true + properties: + page: {type: string, example: '1'} + size: {type: string, example: '200'} + total: {type: string, example: '2'} + pages: {type: string, example: '1'} + records: + type: array + items: + $ref: '#/components/schemas/ReceivingWarehouseOption' + '401': {description: 无有效统一会话} + '403': {description: 无访问权限} /internal/warehouses/order-catalog: get: summary: 查询订单可引用的启用公共仓库目录 @@ -12,7 +50,22 @@ paths: responses: '200': {description: 返回仓库 ID、编码、名称、类型和国家编码} components: + schemas: + ReceivingWarehouseOption: + type: object + required: [id, customerId, warehouseCode, warehouseName, warehouseType, status] + properties: + id: {type: string, description: 权威仓库ID} + customerId: {type: string, enum: ['0'], description: 平台公共资源作用域} + warehouseCode: {type: string} + warehouseName: {type: string} + warehouseType: {type: string, enum: [OVERSEAS]} + status: {type: string, enum: [ACTIVE]} securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT internalServiceToken: type: apiKey in: header