Merge pull request 'feat: production blue-green deployment for all business services' (#14) from test into main

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-09-20 22:22:40 +08:00
38 changed files with 939 additions and 89 deletions
@@ -6,13 +6,11 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication
public class BaiyueAuditApplication {
@@ -7,14 +7,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计 Outbox 的 RocketMQ 发布配置。
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
*/
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
@EnableScheduling
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
@ConditionalOnBean(type = {
"com.baiyue.common.audit.AuditOutboxRepository",
@@ -0,0 +1,13 @@
package com.baiyue.common.web;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.EnableScheduling;
/** Enables scheduled workers by default while allowing blue-green web slots to disable them. */
@AutoConfiguration
@EnableScheduling
@ConditionalOnProperty(prefix = "baiyue.runtime", name = "scheduling-enabled",
havingValue = "true", matchIfMissing = true)
public class BaiyueSchedulingAutoConfiguration {
}
@@ -2,3 +2,4 @@ com.baiyue.common.web.LongIdJacksonConfiguration
com.baiyue.common.web.CommonWebAutoConfiguration
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
com.baiyue.common.web.RestClientTracePropagationAutoConfiguration
com.baiyue.common.web.BaiyueSchedulingAutoConfiguration
@@ -0,0 +1,26 @@
package com.baiyue.common.web;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.scheduling.config.TaskManagementConfigUtils;
import static org.assertj.core.api.Assertions.assertThat;
class BaiyueSchedulingAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(BaiyueSchedulingAutoConfiguration.class));
@Test
void schedulingIsEnabledByDefault() {
runner.run(context -> assertThat(context)
.hasBean(TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME));
}
@Test
void blueGreenWebSlotCanDisableScheduling() {
runner.withPropertyValues("baiyue.runtime.scheduling-enabled=false")
.run(context -> assertThat(context)
.doesNotHaveBean(TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME));
}
}
+14 -8
View File
@@ -2,10 +2,16 @@ name: firstleg-${DEPLOY_ENV:-test}-overseas
x-overseas-common: &overseas-common
restart: "on-failure:3"
stop_grace_period: ${STOP_GRACE_PERIOD:-45s}
networks: [firstleg-overseas-network, infra-network]
extra_hosts:
- "host.docker.internal:host-gateway"
environment: &overseas-environment
BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling}
BAIYUE_RELEASE_ID: ${RELEASE_ID:-local}
BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true}
SERVER_SHUTDOWN: graceful
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s}
SPRING_PROFILES_ACTIVE: overseas
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
NACOS_USERNAME: ${OVERSEAS_NACOS_USERNAME}
@@ -37,8 +43,8 @@ services:
gateway-overseas:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PUBLISHED_PORT:-${GATEWAY_OVERSEAS_PORT:-9300}}:9300"]
environment:
<<: *overseas-environment
SERVER_PORT: 9300
@@ -53,8 +59,8 @@ services:
lastleg-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PUBLISHED_PORT:-${LASTLEG_PORT:-9303}}:9303"]
environment:
<<: *overseas-environment
SERVER_PORT: 9303
@@ -72,8 +78,8 @@ services:
warehouse-overseas-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PUBLISHED_PORT:-${WAREHOUSE_OVERSEAS_PORT:-9305}}:9305"]
environment:
<<: *overseas-environment
SERVER_PORT: 9305
@@ -87,8 +93,8 @@ services:
event-bridge-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-${EVENT_BRIDGE_OVERSEAS_PORT:-9313}}:9312"]
environment:
<<: *overseas-environment
SERVER_PORT: 9312
+29 -23
View File
@@ -2,12 +2,18 @@ name: firstleg-${DEPLOY_ENV:-test}
x-common: &common
restart: "on-failure:3"
stop_grace_period: ${STOP_GRACE_PERIOD:-45s}
networks: [firstleg-network, infra-network]
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
extra_hosts:
- "host.docker.internal:host-gateway"
environment: &common-environment
DEPLOY_ENV: ${DEPLOY_ENV:-test}
BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling}
BAIYUE_RELEASE_ID: ${RELEASE_ID:-local}
BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true}
SERVER_SHUTDOWN: graceful
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn}
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
NACOS_USERNAME: ${NACOS_USERNAME}
@@ -39,8 +45,8 @@ services:
gateway:
<<: *common
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
container_name: firstleg-${DEPLOY_ENV:-test}-gateway${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"]
environment:
<<: *common-environment
SERVER_PORT: 9000
@@ -51,8 +57,8 @@ services:
auth:
<<: *common
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-auth
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
container_name: firstleg-${DEPLOY_ENV:-test}-auth${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PUBLISHED_PORT:-${AUTH_PORT:-9100}}:9100"]
environment:
<<: *common-environment
SERVER_PORT: 9100
@@ -69,8 +75,8 @@ services:
iam:
<<: *common
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-iam
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
container_name: firstleg-${DEPLOY_ENV:-test}-iam${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PUBLISHED_PORT:-${IAM_PORT:-9201}}:9201"]
environment:
<<: *common-environment
SERVER_PORT: 9201
@@ -82,8 +88,8 @@ services:
firstleg:
<<: *common
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-business
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
container_name: firstleg-${DEPLOY_ENV:-test}-business${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PUBLISHED_PORT:-${FIRSTLEG_PORT:-9202}}:9202"]
environment:
<<: *common-environment
SERVER_PORT: 9202
@@ -115,8 +121,8 @@ services:
finance:
<<: *common
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-finance
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
container_name: firstleg-${DEPLOY_ENV:-test}-finance${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PUBLISHED_PORT:-${FINANCE_PORT:-9203}}:9203"]
environment:
<<: *common-environment
SERVER_PORT: 9203
@@ -128,8 +134,8 @@ services:
order:
<<: *common
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-order
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
container_name: firstleg-${DEPLOY_ENV:-test}-order${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PUBLISHED_PORT:-${ORDER_PORT:-9204}}:9204"]
environment:
<<: *common-environment
SERVER_PORT: 9204
@@ -140,8 +146,8 @@ services:
warehouse:
<<: *common
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PUBLISHED_PORT:-${WAREHOUSE_PORT:-9205}}:9205"]
environment:
<<: *common-environment
SERVER_PORT: 9205
@@ -152,8 +158,8 @@ services:
file:
<<: *common
image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-file
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
container_name: firstleg-${DEPLOY_ENV:-test}-file${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PUBLISHED_PORT:-${FILE_PORT:-9206}}:9206"]
volumes:
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
environment:
@@ -176,8 +182,8 @@ services:
print:
<<: *common
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-print
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
container_name: firstleg-${DEPLOY_ENV:-test}-print${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PUBLISHED_PORT:-${PRINT_PORT:-9207}}:9207"]
environment:
<<: *common-environment
SERVER_PORT: 9207
@@ -186,8 +192,8 @@ services:
integration-service:
<<: *common
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-integration
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
container_name: firstleg-${DEPLOY_ENV:-test}-integration${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PUBLISHED_PORT:-${INTEGRATION_PORT:-9210}}:9210"]
environment:
<<: *common-environment
SERVER_PORT: 9210
@@ -235,8 +241,8 @@ services:
event-bridge-service:
<<: *common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge${DEPLOY_SLOT_SUFFIX:-}
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PUBLISHED_PORT:-${EVENT_BRIDGE_PORT:-9312}}:9312"]
environment:
<<: *common-environment
SERVER_PORT: 9312
@@ -256,7 +262,7 @@ services:
audit-service:
<<: *common
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-audit
container_name: firstleg-${DEPLOY_ENV:-test}-audit${DEPLOY_SLOT_SUFFIX:-}
expose: ["9700"]
environment:
<<: *common-environment
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 7 ]; then
echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件> <Compose文件> <项目名>" >&2
exit 2
fi
DEPLOY_ENV="$1"
IMAGE_REPOSITORY="$2"
IMAGE_TAG="$3"
SERVICE="$4"
ENV_FILE="$5"
COMPOSE_FILE="$6"
BASE_PROJECT="$7"
case "$DEPLOY_ENV" in
test|production) ;;
*) echo "蓝绿执行器仅用于 test/production,当前: $DEPLOY_ENV" >&2; exit 2 ;;
esac
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
[ -f "$file" ] || { echo "必需文件不存在: $file" >&2; exit 2; }
done
case "$SERVICE" in
*[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2; exit 2 ;;
esac
env_value() {
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
}
service_contract() {
case "$SERVICE:$COMPOSE_FILE" in
gateway:*) echo "gateway|9000|9000|gateway|gateway-active" ;;
auth:*) echo "auth|9100|9100|auth-service|auth" ;;
iam:*) echo "iam|9201|9201|iam-service|iam" ;;
firstleg:*) echo "business|9202|9202|firstleg-service|firstleg" ;;
finance:*) echo "finance|9203|9203|finance-service|finance" ;;
order:*) echo "order|9204|9204|order-service|order" ;;
warehouse:*) echo "warehouse|9205|9205|warehouse-service|warehouse" ;;
file:*) echo "file|9206|9206|file-service|file" ;;
print:*) echo "print|9207|9207|print-service|print" ;;
integration-service:*) echo "integration|9210|9210|integration-service|integration-service" ;;
audit-service:*) echo "audit|9700|9700|audit-service|audit-service" ;;
gateway-overseas:*) echo "gateway-overseas|9300|9300|gateway-overseas|gateway-overseas-active" ;;
lastleg-service:*) echo "lastleg|9303|9303|lastleg-service|lastleg-service" ;;
warehouse-overseas-service:*) echo "warehouse-overseas|9305|9305|warehouse-overseas-service|warehouse-overseas-service" ;;
event-bridge-service:*overseas*) echo "event-bridge-overseas|9312|9313|event-bridge-overseas|event-bridge-overseas" ;;
event-bridge-service:*) echo "event-bridge|9312|9312|event-bridge-cn|event-bridge-cn" ;;
*) echo "不支持蓝绿部署的服务: $SERVICE" >&2; exit 2 ;;
esac
}
IFS='|' read -r CONTAINER_STEM INTERNAL_PORT BASE_PORT NACOS_SERVICE ROUTING_ALIAS <<EOF
$(service_contract)
EOF
STATE_ROOT="${BLUE_GREEN_STATE_ROOT:-/data/firstleg/deploy/blue-green-state}"
STATE_KEY="${BASE_PROJECT}-${SERVICE}"
STATE_FILE="$STATE_ROOT/$STATE_KEY.active"
ROUTING_NETWORK="${BLUE_GREEN_ROUTING_NETWORK:-firstleg-${DEPLOY_ENV}-routing}"
HEALTH_TIMEOUT="${DEPLOY_HEALTH_TIMEOUT_SECONDS:-240}"
HEALTH_POLL="${DEPLOY_HEALTH_POLL_SECONDS:-5}"
STABILIZATION_SECONDS="${BLUE_GREEN_STABILIZATION_SECONDS:-10}"
STOP_TIMEOUT="${BLUE_GREEN_STOP_TIMEOUT_SECONDS:-45}"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
for value in "$HEALTH_TIMEOUT" "$HEALTH_POLL" "$STABILIZATION_SECONDS" "$STOP_TIMEOUT"; do
case "$value" in ''|*[!0-9]*) echo "蓝绿时间参数必须为非负整数: $value" >&2; exit 2 ;; esac
done
mkdir -p "$STATE_ROOT"
chmod 750 "$STATE_ROOT"
docker network inspect "$ROUTING_NETWORK" >/dev/null 2>&1 || docker network create "$ROUTING_NETWORK" >/dev/null
legacy_container="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}"
active_slot=""
active_container=""
active_port=""
active_worker=""
if [ -s "$STATE_FILE" ]; then
IFS='|' read -r active_slot active_container active_port active_image active_worker < "$STATE_FILE"
elif docker inspect "$legacy_container" >/dev/null 2>&1; then
active_slot="legacy"
active_container="$legacy_container"
active_port="$BASE_PORT"
fi
if [ "$active_slot" = "blue" ]; then
target_slot="green"
else
target_slot="blue"
[ "$active_slot" = "legacy" ] && target_slot="green"
fi
if [ "$target_slot" = "blue" ]; then
target_port="$BASE_PORT"
else
target_port=$((BASE_PORT + 10000))
fi
candidate="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}-${target_slot}"
worker_candidate="${candidate}-worker"
if docker inspect "$candidate" >/dev/null 2>&1; then
if [ "$candidate" = "$active_container" ]; then
echo "活动槽状态异常,候选容器与活动容器相同: $candidate" >&2
exit 1
fi
docker rm -f "$candidate" >/dev/null
fi
compose() {
docker compose --project-name "$BASE_PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
container_health() {
container="$1"
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \
"$container" 2>/dev/null || echo missing
}
cleanup_candidate() {
docker rm -f "$candidate" >/dev/null 2>&1 || true
}
wait_for_container() {
container="$1"
label="$2"
elapsed=0
while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do
state="$(container_health "$container")"
echo "$label 健康状态: $state(已等待 ${elapsed}s)"
case "$state" in
healthy) return 0 ;;
unhealthy|exited|dead|missing) return 1 ;;
esac
sleep "$HEALTH_POLL"
elapsed=$((elapsed + HEALTH_POLL))
done
return 1
}
needs_worker() {
case "$SERVICE" in
iam|firstleg|finance|order|event-bridge-service|lastleg-service) return 0 ;;
file)
[ "$(env_value FILE_EXPORT_CONSUMER_ENABLED)" = true ] \
|| [ "$(env_value FILE_EXPORT_PUBLISHER_ENABLED)" = true ] \
|| [ "$(env_value FILE_EXPORT_RECONCILER_ENABLED)" = true ] \
|| [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ]
;;
audit-service)
[ "$(env_value AUDIT_MQ_CONSUMER_ENABLED)" = true ] \
|| [ "$(env_value AUDIT_DLQ_CONSUMER_ENABLED)" = true ] \
|| [ "$(env_value AUDIT_DLQ_COMPENSATION_ENABLED)" = true ]
;;
auth|warehouse|warehouse-overseas-service)
[ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ]
;;
*) return 1 ;;
esac
}
start_worker_candidate() {
docker rm -f "$worker_candidate" >/dev/null 2>&1 || true
echo "启动单例 Worker: $worker_candidate"
if [ "$SERVICE" = file ]; then
worker_java_options="-Xms512m -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
else
worker_java_options="-Xms256m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
fi
if ! compose run -d --no-deps --name "$worker_candidate" \
-e "NACOS_REGISTER_ENABLED=false" \
-e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=true" \
-e "BAIYUE_DEPLOY_SLOT=${target_slot}-worker" \
-e "JAVA_TOOL_OPTIONS=$worker_java_options" \
-e "BAIYUE_RELEASE_ID=$IMAGE_TAG" "$SERVICE" >/dev/null; then
return 1
fi
docker network connect "$ROUTING_NETWORK" "$worker_candidate"
if ! wait_for_container "$worker_candidate" "候选 Worker"; then
docker logs --tail 200 "$worker_candidate" 2>&1 || true
docker rm -f "$worker_candidate" >/dev/null 2>&1 || true
return 1
fi
worker_image="$(docker inspect --format '{{.Config.Image}}' "$worker_candidate")"
[ "$worker_image" = "$IMAGE" ] || return 1
}
connect_routing_aliases() {
container="$1"
shift
docker network inspect "$ROUTING_NETWORK" --format '{{json .Containers}}' | grep -q "\"$container\"" \
&& return 0
set -- docker network connect $(for alias_name in "$@"; do printf '%s\n' --alias "$alias_name"; done)
"$@" "$ROUTING_NETWORK" "$container"
}
bootstrap_legacy_routes() {
for pair in \
gateway-active,gateway:gateway auth,auth-service:auth iam,iam-service:iam \
firstleg,firstleg-service:business finance,finance-service:finance order,order-service:order \
warehouse,warehouse-service:warehouse file,file-service:file print,print-service:print \
integration-service:integration audit-service:audit \
gateway-overseas-active,gateway-overseas:gateway-overseas \
lastleg-service:lastleg warehouse-overseas-service:warehouse-overseas \
event-bridge-cn:event-bridge event-bridge-overseas:event-bridge-overseas
do
aliases="${pair%%:*}"
stem="${pair#*:}"
container="firstleg-${DEPLOY_ENV}-${stem}"
if docker inspect "$container" >/dev/null 2>&1; then
connect_routing_aliases "$container" $(printf '%s' "$aliases" | tr ',' ' ') || true
fi
done
for container in firstleg-prod-admin-web firstleg-prod-client-web; do
if docker inspect "$container" >/dev/null 2>&1; then
connect_routing_aliases "$container" "${container##*-}" || true
fi
done
}
switch_gateway_proxy() {
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
if [ "$DEPLOY_ENV" = test ]; then
echo "测试 Gateway 仍由固定入口承载,不在后端蓝绿执行器中切换" >&2
return 1
fi
if [ "$SERVICE" = "gateway" ]; then required_alias=gateway-active; else required_alias=gateway-overseas-active; fi
for frontend_service in admin-web client-web; do
frontend_state="$STATE_ROOT/frontend-$frontend_service.active"
if [ ! -s "$frontend_state" ]; then
echo "$frontend_service 尚未完成首次蓝绿迁移,拒绝切换 Gateway" >&2
return 1
fi
IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state"
if ! docker inspect "$frontend" >/dev/null 2>&1 \
|| ! docker exec "$frontend" nginx -T 2>&1 | grep -q "$required_alias"; then
echo "$frontend 尚未使用稳定上游 $required_alias,拒绝切换 Gateway" >&2
return 1
fi
done
if [ "$SERVICE" = "gateway" ]; then
config="$(env_value GATEWAY_BLUE_GREEN_NGINX_CONFIG)"
config="${config:-/www/server/panel/vhost/nginx/api-cn.buggoglobal.com.conf}"
else
config="$(env_value GATEWAY_OVERSEAS_BLUE_GREEN_NGINX_CONFIG)"
config="${config:-/www/server/panel/vhost/nginx/api-global.buggoglobal.com.conf}"
fi
if [ -z "$config" ] || [ ! -f "$config" ]; then
echo "$SERVICE 蓝绿切换要求环境文件配置有效的 Nginx 配置路径" >&2
return 1
fi
backup="$config.blue-green-backup"
cp -f "$config" "$backup"
if ! sed -i "s#127\.0\.0\.1:${active_port:-$BASE_PORT}#127.0.0.1:$target_port#g" "$config" \
|| cmp -s "$config" "$backup"; then
cp -f "$backup" "$config"
echo "未能在 Nginx 配置中切换 $SERVICE 上游端口" >&2
return 1
fi
if ! nginx -t || ! nginx -s reload; then
cp -f "$backup" "$config"
nginx -t && nginx -s reload || true
return 1
fi
rm -f "$backup"
}
reload_frontend_proxies() {
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
[ "$DEPLOY_ENV" = production ] || return 0
for frontend_service in admin-web client-web; do
frontend_state="$STATE_ROOT/frontend-$frontend_service.active"
[ -s "$frontend_state" ] || return 1
IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state"
docker inspect "$frontend" >/dev/null 2>&1 || return 1
docker exec "$frontend" nginx -t
docker exec "$frontend" nginx -s reload
done
}
rollback_gateway_proxy() {
[ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0
[ "$DEPLOY_ENV" = production ] || return 0
if [ "$SERVICE" = "gateway" ]; then
config="$(env_value GATEWAY_BLUE_GREEN_NGINX_CONFIG)"
config="${config:-/www/server/panel/vhost/nginx/api-cn.buggoglobal.com.conf}"
else
config="$(env_value GATEWAY_OVERSEAS_BLUE_GREEN_NGINX_CONFIG)"
config="${config:-/www/server/panel/vhost/nginx/api-global.buggoglobal.com.conf}"
fi
old_port="${active_port:-$BASE_PORT}"
sed -i "s#127\.0\.0\.1:$target_port#127.0.0.1:$old_port#g" "$config"
nginx -t && nginx -s reload
}
bootstrap_legacy_routes
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
echo "启动候选槽: service=$SERVICE slot=$target_slot port=$target_port image=$IMAGE"
if ! compose run -d --no-deps --name "$candidate" \
-e "BAIYUE_DEPLOY_SLOT=$target_slot" -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" \
-e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=false" \
-e "AUDIT_OUTBOX_PUBLISHER_ENABLED=false" \
-e "NOTIFICATION_SCHEDULER_ENABLED=false" \
-e "NOTIFICATION_MQ_CONSUMER_ENABLED=false" \
-e "FILE_EXPORT_CONSUMER_ENABLED=false" \
-e "FILE_EXPORT_PUBLISHER_ENABLED=false" \
-e "FILE_EXPORT_RECONCILER_ENABLED=false" \
-e "AUDIT_MQ_CONSUMER_ENABLED=false" \
-e "AUDIT_DLQ_CONSUMER_ENABLED=false" \
-e "AUDIT_DLQ_COMPENSATION_ENABLED=false" \
-p "${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:$target_port:$INTERNAL_PORT" "$SERVICE" >/dev/null; then
cleanup_candidate
exit 1
fi
if ! wait_for_container "$candidate" "候选 Web 槽 $target_slot"; then
echo "候选槽未通过健康检查,保留旧实例" >&2
docker logs --tail 200 "$candidate" 2>&1 || true
cleanup_candidate
exit 1
fi
running_image="$(docker inspect --format '{{.Config.Image}}' "$candidate")"
if [ "$running_image" != "$IMAGE" ]; then
echo "候选镜像不一致: expected=$IMAGE actual=$running_image" >&2
cleanup_candidate
exit 1
fi
if [ "$NACOS_SERVICE" = "$ROUTING_ALIAS" ]; then
connect_routing_aliases "$candidate" "$ROUTING_ALIAS"
else
connect_routing_aliases "$candidate" "$ROUTING_ALIAS" "$NACOS_SERVICE"
fi
if [ "$STABILIZATION_SECONDS" -gt 0 ]; then
echo "候选槽稳定观察 ${STABILIZATION_SECONDS}s"
sleep "$STABILIZATION_SECONDS"
[ "$(container_health "$candidate")" = "healthy" ] || { cleanup_candidate; exit 1; }
fi
if ! switch_gateway_proxy; then
cleanup_candidate
exit 1
fi
worker_required=false
if needs_worker; then worker_required=true; fi
if [ "$worker_required" = true ] && [ "$active_slot" != legacy ]; then
if [ -n "$active_worker" ] && docker inspect "$active_worker" >/dev/null 2>&1; then
echo "暂停旧 Worker,避免新旧任务并行: $active_worker"
docker stop --time "$STOP_TIMEOUT" "$active_worker" >/dev/null
fi
if ! start_worker_candidate; then
[ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true
rollback_gateway_proxy || true
cleanup_candidate
exit 1
fi
fi
old_container_stopped=false
if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then
echo "优雅停止旧 Web 实例: $active_container"
docker stop --time "$STOP_TIMEOUT" "$active_container" >/dev/null
old_container_stopped=true
fi
if [ "$worker_required" = true ] && [ "$active_slot" = legacy ]; then
if ! start_worker_candidate; then
[ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true
rollback_gateway_proxy || true
cleanup_candidate
exit 1
fi
fi
if ! reload_frontend_proxies; then
[ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true
if [ "$worker_required" = true ]; then
docker rm -f "$worker_candidate" >/dev/null 2>&1 || true
[ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true
fi
rollback_gateway_proxy || true
cleanup_candidate
reload_frontend_proxies || true
exit 1
fi
[ -z "$active_worker" ] || [ "$active_worker" = "$worker_candidate" ] \
|| docker rm "$active_worker" >/dev/null 2>&1 || true
[ "$old_container_stopped" = false ] || docker rm "$active_container" >/dev/null
if [ "$worker_required" = true ]; then
next_worker="$worker_candidate"
else
next_worker=""
fi
printf '%s|%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$IMAGE" "$next_worker" > "$STATE_FILE.tmp"
chmod 640 "$STATE_FILE.tmp"
mv -f "$STATE_FILE.tmp" "$STATE_FILE"
echo "蓝绿切换完成: service=$SERVICE active=$target_slot container=$candidate port=$target_port"
+33 -3
View File
@@ -348,6 +348,17 @@ deploy_local() {
if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then
echo "镜像已在目标服务器本机,直接部署: $IMAGE"
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"
sh deploy/scripts/deploy-blue-green-service.sh \
"$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME"
local_registry_logout
trap - EXIT HUP INT TERM
exit 0
fi
deploy_local
exit 0
fi
@@ -389,20 +400,28 @@ run_ssh install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE}"
REMOTE_ENV_NAME=".env.$PROJECT_NAME"
REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml"
REMOTE_BLUE_GREEN_NAME="deploy-blue-green-service.sh"
REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp"
REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$SUFFIX.tmp"
REMOTE_BLUE_GREEN_TMP="$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME.$SUFFIX.tmp"
run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP"
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP"
run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" <<'REMOTE_PREPARE'
run_scp "deploy/scripts/deploy-blue-green-service.sh" "$SSH_TARGET:$REMOTE_BLUE_GREEN_TMP"
run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_BLUE_GREEN_TMP" \
"$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" <<'REMOTE_PREPARE'
set -eu
env_tmp="$1"
compose_tmp="$2"
env_target="$3"
compose_target="$4"
blue_green_tmp="$3"
env_target="$4"
compose_target="$5"
blue_green_target="$6"
chmod 600 "$env_tmp"
chmod 644 "$compose_tmp"
chmod 750 "$blue_green_tmp"
mv -f "$env_tmp" "$env_target"
mv -f "$compose_tmp" "$compose_target"
mv -f "$blue_green_tmp" "$blue_green_target"
REMOTE_PREPARE
remote_registry_logout() {
@@ -414,6 +433,17 @@ 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" ] \
|| { [ "$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"
remote_registry_logout
trap - EXIT HUP INT TERM
exit 0
fi
run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" "$HEALTH_TIMEOUT_SECONDS" "$HEALTH_POLL_SECONDS" <<'REMOTE_DEPLOY'
set -eu
remote_dir="$1"
+18
View File
@@ -188,6 +188,24 @@ if [ "$DEPLOY_ENV" = "development" ]; then
"$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
fi
if [ "$DEPLOY_ENV" = "test" ] || [ "$DEPLOY_ENV" = "production" ]; then
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" \
pull $SERVICES
for service in $SERVICES; do
if [ "$DEPLOY_ENV" = test ] && { [ "$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
exit 0
fi
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
@@ -0,0 +1,31 @@
-- Region: CN
-- Schema: baiyue_iam
-- Purpose: 补齐 PDA 版本页面和发布接口使用的权限资源。
-- Preconditions: 连接必须指向 baiyue_iam;不修改角色、角色授权和数据范围。
-- Postconditions: 两条 PDA 权限资源存在,权限定义与当前初始化基线一致。
SET NAMES utf8mb4;
INSERT INTO iam_permission (
id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in,
created_at, updated_at, deleted
)
SELECT 5101060, 0, 'PLATFORM', 'MENU', 5101000, 'platform:pda-release:list',
'PDA版本', '/system/pda-releases', NULL, 60, 'ACTIVE', 1,
CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), 0
WHERE NOT EXISTS (
SELECT 1 FROM iam_permission WHERE tenant_id = 0 AND permission_code = 'platform:pda-release:list'
);
INSERT INTO iam_permission (
id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in,
created_at, updated_at, deleted
)
SELECT 5101061, 0, 'PLATFORM', 'BUTTON', 5101060, 'platform:pda-release:manage',
'发布PDA版本', NULL, NULL, 10, 'ACTIVE', 1,
CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), 0
WHERE NOT EXISTS (
SELECT 1 FROM iam_permission WHERE tenant_id = 0 AND permission_code = 'platform:pda-release:manage'
);
@@ -7735,4 +7735,4 @@ USE baiyue_finance;
-- 系统基线 fin_subject:1 行。
INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 208 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables;
@@ -6,14 +6,12 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 跨区域事件桥启动入口。
*/
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(EventDispatcherProperties.class)
public class EventBridgeApplication {
public static void main(String[] args) {
@@ -23,6 +23,8 @@ spring:
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:}
config:
@@ -5,7 +5,6 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 文件中心服务启动入口。
@@ -13,7 +12,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = "com.baiyue")
@MapperScan(basePackages = "com.baiyue.file", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.file.client")
@EnableScheduling
public class FileServiceApplication {
public static void main(String[] args) {
SpringApplication.run(FileServiceApplication.class, args);
@@ -5,13 +5,11 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */
@SpringBootApplication
@MapperScan(basePackages = "com.baiyue.finance", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.finance.client")
@EnableScheduling
public class BaiyueFinanceApplication {
public static void main(String[] args) {
@@ -12,7 +12,9 @@ import static org.junit.jupiter.api.Assertions.*;
class FinanceContractTest {
@Test
void applicationEnablesReliableOutboxScheduling() {
assertNotNull(BaiyueFinanceApplication.class.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
assertNull(BaiyueFinanceApplication.class.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
assertNotNull(com.baiyue.common.web.BaiyueSchedulingAutoConfiguration.class
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
}
@Test
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan(basePackages = "com.baiyue.firstleg", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueFirstlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 身份与权限服务启动入口,负责装配租户、成员、双端 RBAC 和授权快照相关组件。
*/
@MapperScan(basePackages = "com.baiyue.iam", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.iam.client")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueIamApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueIamApplication.class, args); }
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
*/
@MapperScan(basePackages = "com.baiyue.lastleg", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.lastleg")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueLastlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
@@ -5,14 +5,12 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 订单微服务启动入口,负责装配订单领域组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan(basePackages = "com.baiyue.order", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.order.client")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueOrderApplication {
public static void main(String[] args) {
@@ -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\}/)
})
@@ -0,0 +1,22 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { replayCompletedCommand } from './phase3-loading-finalize.mjs'
test('completed-command replay retries one connection failure and keeps its diagnostic', async () => {
const failure = { http: 0, raw: 'fetch failed' }
const success = { http: 200, body: { code: '200' } }
const responses = [failure, success]
const diagnostics = []
let calls = 0
assert.equal(await replayCompletedCommand(async () => { calls++; return responses.shift() }, r => diagnostics.push(r)), success)
assert.equal(calls, 2)
assert.deepEqual(diagnostics, [failure])
})
test('business failures are never retried; a second connection failure remains failure', async () => {
for (const response of [{ http: 200, body: { code: 'A0409' } }, { http: 503 }, { http: 0 }]) {
let calls = 0
const result = await replayCompletedCommand(async () => { calls++; return response }, () => {})
assert.equal(result, response)
assert.equal(calls, response.http === 0 ? 2 : 1)
}
})
@@ -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', () => {
@@ -3,6 +3,15 @@ const OPTIONS = { scenario: 'phase3-loading-hard-gate-finalize', deploymentBlock
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
export async function replayCompletedCommand(send, recordTransportFailure) {
const first = await send()
if (first.http !== 0) return first
// Used only after authoritative COMPLETED + operation history have been read.
// The closure retains the exact original body and idempotency key.
recordTransportFailure(first)
return send()
}
function evidenceSignature(data) {
return JSON.stringify((data?.evidences || []).map((item) => ({
id: id(item?.id), type: item?.evidenceType || null, fileId: id(item?.fileId),
@@ -268,8 +277,12 @@ export async function run(context) {
&& id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt)
&& completion.task?.taskStatus === 'COMPLETED' && completion.task?.workflowStatus === 'COMPLETED'
const completeResponse = completion.task?.taskStatus === 'COMPLETED'
? await request(context, 'POST', `${taskPath}/complete`, completeBody,
{ 'Idempotency-Key': completeKey })
? await replayCompletedCommand(() => request(context, 'POST', `${taskPath}/complete`, completeBody,
{ 'Idempotency-Key': completeKey }), failed => {
context.createdResources.loadingCompleteReplayTransportFailures ??= []
context.createdResources.loadingCompleteReplayTransportFailures.push({ taskId,
operationId: completeBody.operationId, http: failed.http, error: failed.raw || 'transport failure' })
})
: acceptedCompleteResponse
context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计',
completeResponse, concurrentCompleteVerified,
@@ -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,9 @@ 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', () => {
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/,
)
assert.match(unifiedSql, /CREATE PROCEDURE assert_phase4_warehouse_role_minimum\(\)/)
test('unified baseline creates the phase-four role matrix in the IAM schema', () => {
assert.match(unifiedSql, /USE baiyue_iam;/)
assert.match(unifiedSql, /'OVERSEAS_WAREHOUSE_PDA_OPERATOR'/)
assert.match(unifiedSql, /'lastleg:pda:operate'/)
assert.doesNotMatch(unifiedSql, /tmp_phase4_warehouse_role_permission|assert_phase4_warehouse_role_minimum/)
})
@@ -106,6 +106,7 @@ test('P5 follows warehouse review, customer confirmation and fulfillment complet
assert.match(source, /manualCompletion: true/)
assert.match(source, /P5返工重新确认箱标阶段/)
assert.match(source, /P5返工重新完成SKU阶段/)
assert.match(source, /newLabel: redoItem\.fbaCartonMark \|\| redoItem\.newSkuCode/)
assert.ok(source.indexOf('P5换标箱${index + 1}拆托') < source.indexOf('P5返工组托后提交完工等待客户确认'))
assert.ok(source.indexOf('P5返工组托后提交完工等待客户确认') < source.indexOf('const confirmBody'))
assert.match(source, /applicationStatus === 'CUSTOMER_CONFIRM_PENDING'/)
@@ -557,7 +557,8 @@ async function verifyRelabel(context, token, merchantToken, fixture) {
detail = carton.data
}
const redo = await request(context, token, 'POST', 'P5返工重新完成SKU阶段', unitPath, {
itemId: id(redoItem.itemId), oldLabel: redoItem.cartonMark, newLabel: redoItem.newSkuCode,
itemId: id(redoItem.itemId), oldLabel: redoItem.cartonMark,
newLabel: redoItem.fbaCartonMark || redoItem.newSkuCode,
skuVerifications: (redoItem.skuItems || []).map(sku => ({
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
})),
@@ -254,6 +254,20 @@ function previewGroupsSorted(groups) {
async function convertContainerPlans(context, producer, details) {
const bookingId = id(producer.containerBookingId)
const arrival = await api(context, 'POST', '按柜转换前读取预计到仓时间',
`${ORDERS}/container-arrival-times/query`, { sourceContainerBookingIds: [bookingId] })
const currentArrival = Array.isArray(arrival.data)
? arrival.data.find(row => id(row.sourceContainerBookingId) === bookingId) : null
if (!success(arrival.response) || !currentArrival || !Number.isInteger(Number(currentArrival.version))) return null
if (!nonBlank(currentArrival.estimatedWarehouseArrivalAt)) {
const expected = await api(context, 'PUT', '按柜转换前补齐预计到仓时间',
`${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, {
estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19),
expectedVersion: Number(currentArrival.version), remark: `${context.marker}-container-plan-arrival`,
}, { 'Idempotency-Key': `${context.marker}-container-plan-arrival`.slice(0, 128) })
if (!success(expected.response) || id(expected.data?.sourceContainerBookingId) !== bookingId
|| !nonBlank(expected.data?.estimatedWarehouseArrivalAt)) return null
}
const previewPath = query(`${PLANS}/container-preview`, { sourceContainerBookingId: bookingId })
const preview = await api(context, 'GET', '预览整柜待生成入仓计划清单', previewPath)
const groups = Array.isArray(preview.data?.groups) ? preview.data.groups : []
@@ -254,17 +254,19 @@ export async function run(context) {
const merchantToken = await loginMerchant(context, customerId)
if (!merchantToken) return
const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
const updateBody = {
vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64),
driverName: `五期验收司机-${context.marker.slice(-6)}`,
driverPhone: '19900000000', carrierName: '五期开发验收车队',
route: `${context.marker}-PHASE5-ROUTE`, estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
}
const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路',
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody)
context.assert(MODULE, '排车编辑字段完整回显', updated.response,
updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === updateBody.route,
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route,
trace(updated.response), OPTIONS)
if (!success(updated.response)) return
@@ -0,0 +1,100 @@
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 \+ 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, /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, /api-cn\.buggoglobal\.com\.conf/)
assert.match(script, /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, /if \[ "\$DEPLOY_ENV" = "test" \] \|\| \[ "\$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 \]/)
})
@@ -0,0 +1,164 @@
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',
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/)
})
@@ -76,7 +76,7 @@ test('overseas compose is isolated from CN defaults and disables implicit schema
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_PORT:-9313}:9312/)
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\}/)
@@ -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/)
@@ -180,10 +180,12 @@ test('parallel remote deployments keep env and compose assets isolated by projec
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="\$3"/)
assert.match(directDeployScript, /compose_target="\$4"/)
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"/)