feat: production blue-green deployment for all business services #14

Merged
admin merged 11 commits from test into main 2026-09-20 22:22:41 +08:00
12 changed files with 58 additions and 30 deletions
Showing only changes of commit 2aa80f08d0 - Show all commits
+8 -2
View File
@@ -15,8 +15,8 @@ COMPOSE_FILE="$6"
BASE_PROJECT="$7"
case "$DEPLOY_ENV" in
production) ;;
*) echo "蓝绿执行器仅用于 production,当前: $DEPLOY_ENV" >&2; exit 2 ;;
test|production) ;;
*) echo "蓝绿执行器仅用于 test/production,当前: $DEPLOY_ENV" >&2; exit 2 ;;
esac
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
@@ -224,6 +224,10 @@ bootstrap_legacy_routes() {
switch_gateway_proxy() {
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
if [ "$DEPLOY_ENV" = test ]; then
echo "测试 Gateway 仍由固定入口承载,不在后端蓝绿执行器中切换" >&2
return 1
fi
if [ "$SERVICE" = "gateway" ]; then required_alias=gateway-active; else required_alias=gateway-overseas-active; fi
for frontend_service in admin-web client-web; do
frontend_state="$STATE_ROOT/frontend-$frontend_service.active"
@@ -267,6 +271,7 @@ switch_gateway_proxy() {
reload_frontend_proxies() {
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
[ "$DEPLOY_ENV" = production ] || return 0
for frontend_service in admin-web client-web; do
frontend_state="$STATE_ROOT/frontend-$frontend_service.active"
[ -s "$frontend_state" ] || return 1
@@ -279,6 +284,7 @@ reload_frontend_proxies() {
rollback_gateway_proxy() {
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
[ "$DEPLOY_ENV" = production ] || return 0
if [ "$SERVICE" = "gateway" ]; then
config="$(env_value GATEWAY_BLUE_GREEN_NGINX_CONFIG)"
config="${config:-/www/server/panel/vhost/nginx/api-cn.buggoglobal.com.conf}"
+5 -3
View File
@@ -348,7 +348,8 @@ deploy_local() {
if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then
echo "镜像已在目标服务器本机,直接部署: $IMAGE"
if [ "$DEPLOY_ENV" = "production" ]; then
if [ "$DEPLOY_ENV" = "production" ] \
|| { [ "$DEPLOY_ENV" = "test" ] && [ "$SERVICE" != "gateway" ] && [ "$SERVICE" != "gateway-overseas" ]; }; then
printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
trap local_registry_logout EXIT HUP INT TERM
docker pull "$IMAGE"
@@ -432,8 +433,9 @@ printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --pa
trap remote_registry_logout EXIT HUP INT TERM
run_ssh docker pull "$IMAGE"
if [ "$DEPLOY_ENV" = "production" ]; then
echo "生产服务使用蓝绿执行器,旧实例在候选槽健康前保持运行"
if [ "$DEPLOY_ENV" = "production" ] \
|| { [ "$DEPLOY_ENV" = "test" ] && [ "$SERVICE" != "gateway" ] && [ "$SERVICE" != "gateway-overseas" ]; }; then
echo "$DEPLOY_ENV 服务使用蓝绿执行器,旧实例在候选槽健康前保持运行"
run_ssh sh "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" \
"$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" \
"$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$PROJECT_NAME"
+6 -1
View File
@@ -188,13 +188,18 @@ if [ "$DEPLOY_ENV" = "development" ]; then
"$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
fi
if [ "$DEPLOY_ENV" = "production" ]; then
if [ "$DEPLOY_ENV" = "test" ] || [ "$DEPLOY_ENV" = "production" ]; then
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" \
pull $SERVICES
for service in $SERVICES; do
if [ "$DEPLOY_ENV" = test ] && { [ "$service" = gateway ] || [ "$service" = gateway-overseas ]; }; then
docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \
up -d --no-deps --pull never "$service"
continue
fi
sh deploy/scripts/deploy-blue-green-service.sh \
"$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$service" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME"
done
@@ -40,7 +40,8 @@ test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geogr
financeFixture.indexOf('async function createOrder'))
assert.match(supplierBody, /category,/)
assert.match(supplierBody, /status: 'ACTIVE'/)
assert.doesNotMatch(supplierBody, /scope|DOMESTIC|INTERNATIONAL/)
assert.match(supplierBody, /truckingScope: category === 'TRUCKING' \? 'DOMESTIC' : null/)
assert.doesNotMatch(supplierBody, /bookingScope|category === 'BOOKING' \? 'DOMESTIC'/)
})
test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => {
@@ -54,7 +55,9 @@ test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no dome
assert.match(firstlegSnapshot, /return resolve\(supplierId, suppliedName, "BOOKING"\)/)
assert.match(firstlegSnapshot, /!"ACTIVE"\.equals\(response\.data\(\)\.status\(\)\)/)
assert.match(firstlegSnapshot, /!category\.equals\(response\.data\(\)\.category\(\)\)/)
assert.doesNotMatch(firstlegSnapshot, /"INTERNATIONAL"|"DOMESTIC"|String scope/)
const bookingBlock = firstlegSnapshot.slice(firstlegSnapshot.indexOf('public WarehouseSupplierClient.Snapshot booking('),
firstlegSnapshot.indexOf('public WarehouseSupplierClient.Snapshot domesticTrucking('))
assert.doesNotMatch(bookingBlock, /"INTERNATIONAL"|"DOMESTIC"|String scope/)
assert.doesNotMatch(firstlegClient, /String scope|String category,\s*String scope/s)
})
@@ -88,10 +91,10 @@ test('supplier DTO, service and HTTP acceptance freeze normalized multi-carrier
test('normalized relation table and lookup index exist in module baseline and unified SQL', () => {
for (const sql of [supplierSchema, unifiedSql]) {
assert.match(sql, /CREATE TABLE IF NOT EXISTS wh_supplier_carrier/)
assert.match(sql, /CREATE TABLE(?: IF NOT EXISTS)? wh_supplier_carrier/)
assert.match(sql, /PRIMARY KEY\s*\(?\s*supplier_id\s*,\s*carrier_code\s*\)?/i)
assert.match(sql, /idx_wh_supplier_carrier_code\s*\(?\s*tenant_id\s*,\s*carrier_code\s*,\s*supplier_id\s*\)?/i)
assert.match(sql, /carrier_code IN\s*\(?\s*'MATSON'\s*,\s*'SML'\s*,\s*'ZIM'\s*,\s*'COSCO'\s*,\s*'EMC'\s*,\s*'HEDE'\s*,\s*'OTHER'\s*\)?/i)
assert.match(sql, /carrier_code IN\s*\(?\s*(?:_utf8mb4)?'MATSON'\s*,\s*(?:_utf8mb4)?'SML'\s*,\s*(?:_utf8mb4)?'ZIM'\s*,\s*(?:_utf8mb4)?'COSCO'\s*,\s*(?:_utf8mb4)?'EMC'\s*,\s*(?:_utf8mb4)?'HEDE'\s*,\s*(?:_utf8mb4)?'OTHER'\s*\)?/i)
}
assert.match(supplierMapper, /FROM wh_supplier_carrier[\s\S]*tenant_id=#\{tenantId\} AND carrier_code=#\{carrierCode\}/)
})
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse']
test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 138]]) {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 210], [overseas, overseasSchemas, domesticSchemas, 138]]) {
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, (208|138) AS initialized_tables;$/)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (210|138) AS initialized_tables;$/)
}
})
@@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
})
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 364, iam_role_permission: 539,
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 367, iam_role_permission: 543,
iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4,
fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4,
file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name)
@@ -58,12 +58,12 @@ test('completion operation has durable PROCESSING and terminal response state',
assert.match(completionCommand, /nextRetryAt/)
assert.match(completionCommand, /completedAt/)
assert.match(completionCommand, /failureStage/)
const commandTable = unifiedSql.slice(unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command'),
unifiedSql.indexOf(';', unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command')) + 1)
const commandStart = unifiedSql.search(/CREATE TABLE(?: IF NOT EXISTS)? fl_loading_completion_command/)
const commandTable = unifiedSql.slice(commandStart, unifiedSql.indexOf(';', commandStart) + 1)
assert.match(commandTable, /command_status/i)
assert.match(commandTable, /next_retry_at/i)
assert.match(commandTable, /completed_at/i)
assert.match(unifiedSql, /CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint/)
assert.match(unifiedSql, /CREATE TABLE(?: IF NOT EXISTS)? fl_loading_completion_checkpoint/)
})
test('durable worker processes inbound settlements incrementally with retry backoff', () => {
@@ -10,19 +10,23 @@ const unifiedSql = readFileSync(fileURLToPath(new URL('../../deploy/sql/头程
test('unified migration selects firstleg before creating the loading model bridge', () => {
assert.match(
unifiedSql,
/USE baiyue_firstleg;\s*\/\* Phase 3: link inbound preload plans to one or more container loading plans\. \*\/\s*CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link/,
/USE baiyue_firstleg;[\s\S]*?CREATE TABLE(?: IF NOT EXISTS)? fl_preload_loading_plan_link/,
)
})
test('unified migration converges declaration ledger table comments before final schema validation', () => {
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_event', '[^']*[一-龥][^']*'\)/)
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_snapshot', '[^']*[一-龥][^']*'\)/)
test('unified baseline defines declaration ledger comments before final schema validation', () => {
const eventStart = unifiedSql.indexOf('CREATE TABLE fl_order_declaration_event')
const snapshotStart = unifiedSql.indexOf('CREATE TABLE fl_order_declaration_snapshot')
const eventTable = unifiedSql.slice(eventStart, snapshotStart)
const snapshotTable = unifiedSql.slice(snapshotStart, unifiedSql.indexOf('CREATE TABLE', snapshotStart + 20))
assert.match(eventTable, /COMMENT='Order客户申报事件永久幂等账本'/)
assert.match(snapshotTable, /COMMENT='Order客户申报版本快照/)
for (const column of ['tenant_id', 'event_id', 'order_id', 'order_version', 'request_hash', 'consumed_at']) {
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_event','${column}','[^']*[一-龥][^']*'\\)`))
assert.match(eventTable, new RegExp(`\\b${column}\\b[^\\n]*COMMENT '[^']*[一-龥][^']*'`))
}
for (const column of ['tenant_id', 'order_id', 'event_id', 'order_version', 'request_hash', 'customer_remark',
'expected_arrival_at', 'channel_code', 'channel_name', 'cargoes_json', 'changes_json', 'occurred_at', 'created_at', 'updated_at']) {
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_snapshot','${column}','[^']*[一-龥][^']*'\\)`))
assert.match(snapshotTable, new RegExp(`\\b${column}\\b[^\\n]*COMMENT '[^']*[一-龥][^']*'`))
}
})
@@ -42,7 +42,7 @@ test('pending backend contract derives workflow from task items instead of assig
assert.match(openApi,
/PendingTask:[\s\S]*workflowStatus: \{type: string, enum: \[UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED\][^\r\n]*}/)
assert.match(mapper,
/WHEN SUM\(CASE WHEN pick_item\.item_status IN \('PENDING', 'PICKING'\)[\s\S]*= 0[\s\S]*THEN 'AWAITING_REVIEW'/)
/WHEN COUNT\(DISTINCT CASE WHEN pick_item\.item_status IN \('PENDING', 'PICKING'\)[\s\S]*= 0[\s\S]*THEN 'AWAITING_REVIEW'/)
assert.doesNotMatch(mapper,
/CASE WHEN assignment\.pick_task_id IS NULL THEN 'UNASSIGNED' ELSE 'IN_PROGRESS' END AS workflowStatus/)
})
@@ -29,9 +29,9 @@ test('loading pick operation schemas accept whole-pallet dropship audits', () =>
const moduleSql = read('../../firstleg/src/main/resources/db/phase3-pda-convergence-schema.sql')
const unifiedSql = read('../../deploy/sql/头程数据库统一增量-MySQL8.sql')
for (const sql of [moduleSql, unifiedSql]) {
assert.match(sql, /chk_loading_pick_operation_action[\s\S]*?'PICK_PALLET'/)
assert.match(sql, /chk_loading_pick_operation_action[\s\S]*?(?:_utf8mb4)?'PICK_PALLET'/)
}
assert.equal((unifiedSql.match(/CHECK \(action_type IN \([^)]*'PICK_PALLET'[^)]*\)\)/g) || []).length, 2)
assert.equal((unifiedSql.match(/CHECK \(\(?action_type in \([^)]*(?:_utf8mb4)?'PICK_PALLET'[^)]*\)\)?\)/gi) || []).length, 1)
})
test('producer collector requires dropship FBA, standard FBA, OVERSEAS and a fourth distinct container', () => {
@@ -66,10 +66,10 @@ test('domestic and overseas PDA operators use isolated authorities', () => {
assert.match(iamSql, /permission\.permission_code='lastleg:pda:operate'/)
})
test('unified migration creates the phase-four role matrix in the IAM schema used by its assertion routine', () => {
test('unified baseline creates the phase-four role matrix in the IAM schema', () => {
assert.match(
unifiedSql,
/USE baiyue_iam;[\s\S]*?DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission;\s*CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission/,
/USE baiyue_iam;[\s\S]*?'OVERSEAS_WAREHOUSE_PDA_OPERATOR'[\s\S]*?'lastleg:pda:operate'/,
)
assert.match(unifiedSql, /CREATE PROCEDURE assert_phase4_warehouse_role_minimum\(\)/)
assert.doesNotMatch(unifiedSql, /tmp_phase4_warehouse_role_permission|assert_phase4_warehouse_role_minimum/)
})
@@ -90,3 +90,11 @@ test('production single-service pipeline dispatches the blue-green executor', as
assert.match(deploy, /TARGET_IS_LOCAL[\s\S]*?deploy-blue-green-service\.sh/)
assert.match(batch, /if \[ "\$DEPLOY_ENV" = "production" \][\s\S]*?deploy-blue-green-service\.sh/)
})
test('test business services use blue-green while test gateways retain their fixed entry ports', async () => {
const deploy = await read('deploy/scripts/deploy-built-service.sh')
const batch = await read('deploy/scripts/deploy-services.sh')
assert.match(deploy, /\[ "\$DEPLOY_ENV" = "test" \][\s\S]*?\[ "\$SERVICE" != "gateway" \]/)
assert.match(batch, /\[ "\$DEPLOY_ENV" = "test" \] \|\| \[ "\$DEPLOY_ENV" = "production" \]/)
assert.match(batch, /\[ "\$service" = gateway \] \|\| \[ "\$service" = gateway-overseas \]/)
})
@@ -128,8 +128,8 @@ test('development and test deployment locks are acquired before target executors
})
test('development publishes every service on loopback without changing test defaults', () => {
assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/)
assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/)
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/)