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} > 状态:${status} ${detail} > 耗时:${duration} > 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} """ String payloadFile = 'wecom-production-notification.json' writeFile( file: payloadFile, text: groovy.json.JsonOutput.toJson([ msgtype: 'markdown', markdown: [content: content] ]), encoding: 'UTF-8' ) sh ''' set -eu payload_file='wecom-production-notification.json' trap 'rm -f "$payload_file"' EXIT HUP INT TERM response="$(curl --fail --silent --show-error \ -H 'Content-Type: application/json' \ --data-binary @"$payload_file" \ "$WECOM_WEBHOOK")" printf '%s\n' "$response" printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' ''' } } 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' } options { timestamps() disableConcurrentBuilds() } stages { 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 } ''' } env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } sh 'git log -1 --oneline' } } 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 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}" } } } } } }