发布当前测试版本至临时灰度环境 #11
+3
-1
@@ -4,6 +4,8 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules.broken-by-path
|
||||
.env.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
API_PROXY_TARGET=http://127.0.0.1:9000
|
||||
VITE_ADMIN_WEB_ORIGIN=http://localhost:5173
|
||||
|
||||
# Test container deployment (Jenkins environment or untracked .env).
|
||||
# API_UPSTREAM: unified Gateway origin, no /api suffix.
|
||||
# TEST_NETWORK: shared test Docker network.
|
||||
API_UPSTREAM=http://gateway:9000
|
||||
TEST_NETWORK=firstleg-test-network
|
||||
IMAGE_TAG=
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
- 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。
|
||||
- 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。
|
||||
- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `contactPhone`,个人资料或成员管理中的登录手机号由 IAM/Auth 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。
|
||||
- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;国内请求使用国内 Access Token,海外请求统一由 `src/api/overseasHttp.ts` 读取短时海外 Token。启动时 Access Token 已过期但 Refresh Token 仍存在,必须先尝试恢复会话;并发或跨标签页 401 共用一次刷新编排,每个原请求最多重放一次,只有明确 401/会话失效才清理 Token 并保留原路径跳转登录页。登录页“记住账号”只保存登录 ID,不改变 Access/Refresh Token 的持久化与续期策略;页面不要复制令牌逻辑或持久化密码、敏感资质内容。
|
||||
- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;全部请求共用统一 Access Token。启动时 Access Token 已过期但 Refresh Token 仍存在,必须先尝试恢复会话;并发或跨标签页 401 共用一次刷新编排,每个原请求最多重放一次,只有明确 401/会话失效才清理 Token 并保留原路径跳转登录页。登录页“记住账号”只保存登录 ID,不改变 Access/Refresh Token 的持久化与续期策略;页面不要复制令牌逻辑或持久化密码、敏感资质内容。
|
||||
- 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。
|
||||
- 头程订单统一使用当前正式读写契约;不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时按根无兼容规则同步删除生产分支、类型、文案、Mock 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。
|
||||
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
|
||||
|
||||
Vendored
+16
-230
@@ -1,252 +1,38 @@
|
||||
def sendProductionWeComNotification(String title, String status, String color, String detail) {
|
||||
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
|
||||
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析'
|
||||
String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
|
||||
String content = """## ${title}
|
||||
> 应用:客户工作台
|
||||
> Job:${env.JOB_NAME}
|
||||
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
|
||||
> 提交:${sourceVersion}
|
||||
> 状态:<font color="${color}">${status}</font>
|
||||
${detail}
|
||||
> 耗时:${duration}
|
||||
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
|
||||
"""
|
||||
String payloadFile = 'wecom-production-notification.json'
|
||||
writeFile(
|
||||
file: payloadFile,
|
||||
text: groovy.json.JsonOutput.toJson([
|
||||
msgtype: 'markdown',
|
||||
markdown: [content: content]
|
||||
]),
|
||||
encoding: 'UTF-8'
|
||||
)
|
||||
sh '''
|
||||
set -eu
|
||||
payload_file='wecom-production-notification.json'
|
||||
trap 'rm -f "$payload_file"' EXIT HUP INT TERM
|
||||
response="$(curl --fail --silent --show-error \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"$payload_file" \
|
||||
"$WECOM_WEBHOOK")"
|
||||
printf '%s\n' "$response"
|
||||
printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])'
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}"
|
||||
}
|
||||
|
||||
environment {
|
||||
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
|
||||
DEVELOPMENT_COMPOSE_PROJECT = 'client-web'
|
||||
REGISTRY_HOST = '172.31.198.7:10082'
|
||||
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
||||
NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'
|
||||
NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'
|
||||
PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/client-web'
|
||||
}
|
||||
|
||||
agent { label 'java-test' }
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('确认提交版本') {
|
||||
stage('检查测试发布参数') {
|
||||
steps {
|
||||
script {
|
||||
env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false'
|
||||
env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false'
|
||||
if (env.IS_PRODUCTION == 'true') {
|
||||
sh '''
|
||||
set -eu
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || {
|
||||
echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2
|
||||
exit 1
|
||||
}
|
||||
'''
|
||||
if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
error('当前仅支持 test,请使用测试环境 Job')
|
||||
}
|
||||
env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
}
|
||||
sh 'git log -1 --oneline'
|
||||
sh '''set -eu
|
||||
export API_UPSTREAM="${API_UPSTREAM:-http://gateway:9000}"
|
||||
export TEST_NETWORK="${TEST_NETWORK:-firstleg-test-network}"
|
||||
: "${VITE_ADMIN_WEB_ORIGIN:?VITE_ADMIN_WEB_ORIGIN 未设置}"
|
||||
docker network inspect "$TEST_NETWORK" >/dev/null
|
||||
docker compose -f docker-compose.test.yml config --quiet
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建并部署测试前端') {
|
||||
when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
docker compose -f docker-compose.test.yml build --pull=false
|
||||
docker compose -f docker-compose.test.yml up -d --remove-orphans
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建并发布生产镜像') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout git.buggoglobal.com >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.buggoglobal.com
|
||||
image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG"
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
||||
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
||||
--build-arg VITE_ADMIN_WEB_ORIGIN=https://admin.buggoglobal.com \
|
||||
-t "$image" .
|
||||
docker push "$image"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
lock(resource: 'firstleg-production-client-web-deploy', inversePrecedence: true) {
|
||||
withCredentials([
|
||||
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
|
||||
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
|
||||
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
|
||||
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
|
||||
]) {
|
||||
withEnv([
|
||||
'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend',
|
||||
'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST
|
||||
]) {
|
||||
sh "sh scripts/deploy-production.sh client-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建并部署开发前端') {
|
||||
when { expression { env.IS_DEVELOPMENT == 'true' } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
image="client-web-development:${SOURCE_TAG}"
|
||||
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
||||
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
||||
--build-arg VITE_ADMIN_WEB_ORIGIN=http://8.163.49.32:9001 \
|
||||
-t "$image" .
|
||||
export IMAGE_TAG="$SOURCE_TAG"
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T client-web wget -qO- http://127.0.0.1/healthz
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署检查') {
|
||||
when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } }
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
docker compose -f docker-compose.test.yml ps
|
||||
sh '''set -eu
|
||||
docker compose -f docker-compose.test.yml build --pull=false
|
||||
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
|
||||
docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'✅ 生产部署成功',
|
||||
'成功',
|
||||
'info',
|
||||
'> 结果:Client 生产前端已完成发布和部署流程'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failure {
|
||||
sh '''
|
||||
if [ "${IS_PRODUCTION:-false}" = "true" ]; then
|
||||
echo "生产部署失败,请查看上方远程部署日志"
|
||||
elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
|
||||
docker logs --tail=100 client-web-development || true
|
||||
else
|
||||
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
||||
fi
|
||||
'''
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'❌ 生产发布失败',
|
||||
'失败',
|
||||
'warning',
|
||||
'> 说明:请打开 Jenkins 构建日志查看失败阶段'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
aborted {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'⏹ 生产部署已取消',
|
||||
'已取消',
|
||||
'comment',
|
||||
'> 说明:生产部署流程被人工取消或中止'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
生产构建:
|
||||
发布资源构建:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
@@ -35,7 +35,7 @@ pnpm test
|
||||
|
||||
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
||||
|
||||
订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经海外 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
|
||||
订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经统一 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。本地代理仅通过 `API_PROXY_TARGET` 配置统一 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
|
||||
|
||||
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
|
||||
|
||||
@@ -47,4 +47,10 @@ pnpm test
|
||||
|
||||
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地订单与客户验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
|
||||
|
||||
联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
|
||||
联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器全部业务统一请求同源 `/api`,由同一代理转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
|
||||
|
||||
## 唯一测试部署入口
|
||||
|
||||
仅保留 `docker-compose.test.yml` 与 test Jenkins Job。发布前注入 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`VITE_ADMIN_WEB_ORIGIN`(管理后台 origin,构建时用于安全代登录)与 `IMAGE_TAG`(Jenkins 从当前提交生成)。`HOST_PORT` 默认 9002 且仅绑定 loopback;宿主机反向代理负责外部入口。可选 `NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE`,私有镜像认证由构建节点管理。不得把本地构建结果描述为测试环境已发布。
|
||||
|
||||
同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
services:
|
||||
client-web:
|
||||
image: client-web-development:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: client-web-development
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9002:80"
|
||||
environment:
|
||||
API_UPSTREAM: "http://firstleg-development-gateway:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
|
||||
networks:
|
||||
- development-backend
|
||||
- development-overseas-backend
|
||||
|
||||
networks:
|
||||
development-backend:
|
||||
external: true
|
||||
name: firstleg-development-network
|
||||
development-overseas-backend:
|
||||
external: true
|
||||
name: firstleg-development-overseas-network
|
||||
@@ -1,23 +0,0 @@
|
||||
name: firstleg-prod-client-web
|
||||
|
||||
services:
|
||||
client-web:
|
||||
image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: firstleg-prod-client-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${HOST_PORT:-9002}:80"
|
||||
environment:
|
||||
API_UPSTREAM: "http://gateway-active:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"
|
||||
networks:
|
||||
- infra-network
|
||||
- routing-network
|
||||
|
||||
networks:
|
||||
infra-network:
|
||||
external: true
|
||||
name: firstleg-prod-infra-network
|
||||
routing-network:
|
||||
external: true
|
||||
name: firstleg-production-routing
|
||||
+17
-7
@@ -1,19 +1,29 @@
|
||||
name: firstleg-test-client-web
|
||||
services:
|
||||
client-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-https://test-admin.buggoglobal.com}"
|
||||
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
||||
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||
image: client-web-test:local
|
||||
VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:?VITE_ADMIN_WEB_ORIGIN is required}"
|
||||
image: client-web-test:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: client-web-test
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
ports:
|
||||
- "${HOST_PORT:-9002}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "127.0.0.1:${HOST_PORT:-9002}:80"
|
||||
environment:
|
||||
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
|
||||
OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"
|
||||
API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}"
|
||||
networks:
|
||||
- test-backend
|
||||
networks:
|
||||
test-backend:
|
||||
external: true
|
||||
name: "${TEST_NETWORK:-firstleg-test-network}"
|
||||
|
||||
@@ -35,17 +35,6 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 海外业务使用独立 Gateway;浏览器前缀只在此处改写一次为 /api。
|
||||
location /overseas-api/ {
|
||||
client_max_body_size 55m;
|
||||
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
||||
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 支持 Vue Router history 路由直接刷新。
|
||||
location / {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "用法: $0 <服务名> <镜像仓库> <镜像标签> <Compose 文件>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SERVICE="$1"
|
||||
IMAGE_REPOSITORY="$2"
|
||||
IMAGE_TAG="$3"
|
||||
COMPOSE_FILE="$4"
|
||||
IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG"
|
||||
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
|
||||
SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}"
|
||||
SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}"
|
||||
SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}"
|
||||
REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}"
|
||||
: "${REGISTRY_USER:?REGISTRY_USER 未设置}"
|
||||
: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}"
|
||||
|
||||
run_ssh() {
|
||||
ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@"
|
||||
}
|
||||
|
||||
run_scp() {
|
||||
scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@"
|
||||
}
|
||||
|
||||
run_ssh install -d -m 750 "$REMOTE_DIR"
|
||||
REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml"
|
||||
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp"
|
||||
run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE'
|
||||
set -eu
|
||||
compose_tmp="$1"
|
||||
compose_target="$2"
|
||||
chmod 644 "$compose_tmp"
|
||||
mv -f "$compose_tmp" "$compose_target"
|
||||
REMOTE_PREPARE
|
||||
|
||||
printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM
|
||||
run_ssh docker pull "$IMAGE"
|
||||
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
remote_dir="$1"
|
||||
service="$2"
|
||||
image_repository="$3"
|
||||
image_tag="$4"
|
||||
compose_file="docker-compose.$service.yml"
|
||||
project_name="firstleg-prod-$service"
|
||||
cd "$remote_dir"
|
||||
export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag"
|
||||
|
||||
compose() {
|
||||
docker compose --project-name "$project_name" -f "$compose_file" "$@"
|
||||
}
|
||||
|
||||
case "$service" in
|
||||
admin-web)
|
||||
base_port=9001
|
||||
nginx_config=/www/server/panel/vhost/nginx/00-blue-green-admin-web.conf
|
||||
legacy_container=firstleg-prod-admin-web
|
||||
;;
|
||||
client-web)
|
||||
base_port=9002
|
||||
nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf
|
||||
legacy_container=firstleg-prod-client-web
|
||||
;;
|
||||
*) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
state_root=/data/firstleg/deploy/blue-green-state
|
||||
state_file="$state_root/frontend-$service.active"
|
||||
routing_network=firstleg-production-routing
|
||||
mkdir -p "$state_root"
|
||||
chmod 750 "$state_root"
|
||||
docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null
|
||||
|
||||
for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do
|
||||
alias_name="${pair%%:*}"
|
||||
container="${pair#*:}"
|
||||
if docker inspect "$container" >/dev/null 2>&1; then
|
||||
docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \
|
||||
|| docker network connect --alias "$alias_name" "$routing_network" "$container"
|
||||
fi
|
||||
done
|
||||
|
||||
active_slot=""
|
||||
active_container=""
|
||||
active_port=""
|
||||
if [ -s "$state_file" ]; then
|
||||
IFS='|' read -r active_slot active_container active_port active_image < "$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; fi
|
||||
[ "$active_slot" = legacy ] && target_slot=green
|
||||
if [ "$target_slot" = blue ]; then target_port=$((base_port + 20000)); else target_port=$((base_port + 10000)); fi
|
||||
candidate="${legacy_container}-${target_slot}"
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
|
||||
if ! compose run -d --no-deps --name "$candidate" \
|
||||
-p "127.0.0.1:$target_port:80" "$service" >/dev/null; then
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
healthy=false
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt 120 ]; do
|
||||
if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
if [ "$healthy" != true ]; then
|
||||
docker logs --tail 100 "$candidate" >&2 || true
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backup="$nginx_config.blue-green-backup"
|
||||
cp -f "$nginx_config" "$backup"
|
||||
if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \
|
||||
|| cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then
|
||||
cp -f "$backup" "$nginx_config"
|
||||
nginx -t && nginx -s reload || true
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$backup"
|
||||
|
||||
if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then
|
||||
docker stop --time 30 "$active_container" >/dev/null
|
||||
docker rm "$active_container" >/dev/null
|
||||
fi
|
||||
printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp"
|
||||
chmod 640 "$state_file.tmp"
|
||||
mv -f "$state_file.tmp" "$state_file"
|
||||
echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag"
|
||||
REMOTE_DEPLOY
|
||||
@@ -1,28 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8'))
|
||||
.replaceAll('\r\n', '\n')
|
||||
const compose = (await readFile(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8'))
|
||||
.replaceAll('\r\n', '\n')
|
||||
|
||||
test('client production deployment switches healthy blue-green slots atomically', () => {
|
||||
const start = script.indexOf('compose run -d --no-deps --name "$candidate"')
|
||||
const health = script.indexOf('docker exec "$candidate" wget')
|
||||
const nginx = script.indexOf('nginx -t || ! nginx -s reload')
|
||||
const stop = script.indexOf('docker stop --time 30 "$active_container"')
|
||||
assert.ok(start >= 0 && start < health && health < nginx && nginx < stop)
|
||||
assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/)
|
||||
assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
|
||||
assert.match(script, /00-blue-green-admin-web\.conf/)
|
||||
assert.match(script, /00-blue-green-client-web\.conf/)
|
||||
assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/)
|
||||
assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
|
||||
})
|
||||
|
||||
test('client uses stable active gateway aliases on the routing network', () => {
|
||||
assert.match(compose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/)
|
||||
assert.match(compose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/)
|
||||
assert.match(compose, /name: firstleg-production-routing/)
|
||||
})
|
||||
@@ -1,67 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
|
||||
const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8')
|
||||
const developmentCompose = readFileSync(new URL('../../docker-compose.development.yml', import.meta.url), 'utf8')
|
||||
const productionCompose = readFileSync(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8')
|
||||
const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8')
|
||||
const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
|
||||
const root = new URL('../../', import.meta.url)
|
||||
const config = readFileSync(new URL('nginx/default.conf.template', root), 'utf8')
|
||||
const compose = readFileSync(new URL('docker-compose.test.yml', root), 'utf8')
|
||||
const jenkins = readFileSync(new URL('Jenkinsfile', root), 'utf8')
|
||||
const vite = readFileSync(new URL('vite.config.ts', root), 'utf8')
|
||||
|
||||
test('allows 50 MiB uploads with multipart overhead through both API proxies', () => {
|
||||
assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 55m;/)
|
||||
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?client_max_body_size 55m;/)
|
||||
assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
|
||||
})
|
||||
|
||||
test('does not cache the SPA entry while keeping hashed assets immutable', () => {
|
||||
assert.match(config, /location = \/index\.html[\s\S]*no-store, no-cache, must-revalidate/)
|
||||
assert.match(config, /location \/assets\/[\s\S]*max-age=31536000, immutable/)
|
||||
assert.match(config, /location \/ \{[\s\S]*no-store, no-cache, must-revalidate[\s\S]*try_files \$uri \$uri\/ \/index\.html/)
|
||||
})
|
||||
|
||||
test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
|
||||
assert.match(testCompose, /- "host\.docker\.internal:host-gateway"/)
|
||||
assert.match(testCompose, /API_UPSTREAM: "\$\{API_UPSTREAM:-http:\/\/host\.docker\.internal:9000\}"/)
|
||||
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "\$\{OVERSEAS_API_UPSTREAM:-http:\/\/host\.docker\.internal:9300\}"/)
|
||||
assert.doesNotMatch(testCompose, /http:\/\/test-api-(?:cn|global)\.buggoglobal\.com/)
|
||||
assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/)
|
||||
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/)
|
||||
test('all business traffic uses one Gateway without rewriting API paths', () => {
|
||||
assert.match(config, /location \/api\//)
|
||||
assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
|
||||
assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
|
||||
assert.match(config, /client_max_body_size 55m;/)
|
||||
assert.match(config, /proxy_set_header Host \$proxy_host;/)
|
||||
assert.doesNotMatch(config, /\/api\/api/)
|
||||
assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/)
|
||||
})
|
||||
|
||||
test('connects the development client proxy to both development gateways', () => {
|
||||
assert.match(developmentCompose, /API_UPSTREAM: "http:\/\/firstleg-development-gateway:9000"/)
|
||||
assert.match(developmentCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/firstleg-development-gateway-overseas:9300"/)
|
||||
assert.match(developmentCompose, /- development-backend/)
|
||||
assert.match(developmentCompose, /- development-overseas-backend/)
|
||||
assert.match(developmentCompose, /development-overseas-backend:[\s\S]*?external: true[\s\S]*?name: firstleg-development-overseas-network/)
|
||||
assert.doesNotMatch(developmentCompose, /test-api|prod(?:uction)?/i)
|
||||
test('test deployment requires an explicit shared network, Gateway and version', () => {
|
||||
for (const key of ['IMAGE_TAG']) {
|
||||
assert.ok(compose.includes('${' + key + ':?'))
|
||||
}
|
||||
assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
|
||||
assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
|
||||
assert.match(compose, /external: true/)
|
||||
assert.match(compose, /127\.0\.0\.1:/)
|
||||
assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
|
||||
assert.match(jenkins, /label 'java-test'/)
|
||||
assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/)
|
||||
assert.match(jenkins, /docker network inspect/)
|
||||
assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/)
|
||||
for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) {
|
||||
assert.equal(existsSync(new URL(file, root)), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps local environments on target nodes and uses credential-managed SSH for production', () => {
|
||||
assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-prod'\) \? 'java-builder'/)
|
||||
assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/)
|
||||
assert.match(jenkinsfile, /docker compose -p "\$DEVELOPMENT_COMPOSE_PROJECT" -f "\$DEVELOPMENT_COMPOSE_FILE" up -d/)
|
||||
assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save/)
|
||||
assert.match(jenkinsfile, /credentialsId: 'firstleg-production-ssh'/)
|
||||
assert.match(jenkinsfile, /git rev-parse origin\/main/)
|
||||
assert.match(productionCompose, /127\.0\.0\.1:\$\{HOST_PORT:-9002\}:80/)
|
||||
assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/)
|
||||
assert.match(productionCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/)
|
||||
assert.match(productionCompose, /name: firstleg-production-routing/)
|
||||
assert.match(productionCompose, /name: firstleg-prod-infra-network/)
|
||||
test('application shell is revalidated and missing chunks remain 404', () => {
|
||||
assert.match(config, /location = \/index\.html/)
|
||||
assert.match(config, /no-store/)
|
||||
assert.match(config, /max-age=31536000, immutable/)
|
||||
assert.match(config, /try_files \$uri =404;/)
|
||||
assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
|
||||
})
|
||||
|
||||
test('uses authenticated internal base images in CI', () => {
|
||||
assert.match(dockerfile, /ARG NODE_IMAGE=node:22-alpine/)
|
||||
assert.match(dockerfile, /ARG NGINX_IMAGE=nginx:1\.27-alpine/)
|
||||
assert.match(dockerfile, /FROM \$\{NODE_IMAGE\} AS build/)
|
||||
assert.match(dockerfile, /FROM \$\{NGINX_IMAGE\}/)
|
||||
assert.match(jenkinsfile, /NODE_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/node:22-alpine'/)
|
||||
assert.match(jenkinsfile, /NGINX_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/nginx:1\.27-alpine'/)
|
||||
assert.match(jenkinsfile, /withCredentials\(\[usernamePassword\(/)
|
||||
assert.match(jenkinsfile, /export DOCKER_CONFIG="\$\{WORKSPACE\}@tmp\/docker-auth"/)
|
||||
|
||||
test('container build excludes local environment values and waits for Nginx health', () => {
|
||||
const ignore = readFileSync(new URL('.dockerignore', root), 'utf8')
|
||||
assert.match(ignore, /^\.env$/m)
|
||||
assert.match(ignore, /^\.env\.\*$/m)
|
||||
assert.match(compose, /healthcheck:/)
|
||||
assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
|
||||
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user