优化
This commit is contained in:
+8
-4
@@ -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 <Outbox主键>` 只读查询状态和摘要,不返回业务负载。追加 `--retry --request-id <稳定UUID> --operator <操作人> --reason <原因> --hash <sha256摘要>` 会写入目标测试服务的 Outbox 与审计表,执行前须已有该环境操作授权。仅 DEAD 原事件可重试,事件身份、负载及摘要保持不变;重复提交使用同一个请求编号,参数变化会被拒绝。三个服务须先部署 `event_delivery_retry_audit` 表,未设置至少32字节的运维凭据时入口关闭。脚本将脱敏结果输出到标准输出,不持久化凭据;离线验证为 `node --test scripts/event-outbox-operations.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('跨区域放错位置的最终字段归正确区域且保留财务精度', () => {
|
||||
|
||||
@@ -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<String> 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
|
||||
|
||||
@@ -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\]/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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"/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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 <direct-service-origin> --entry <id>\nRetry: add --retry --request-id <stable-uuid> --operator <name> --reason <reason> --hash <sha256:...>\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 })
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>([^<]+)<\/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/)
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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.'
|
||||
|
||||
Reference in New Issue
Block a user