diff --git a/.env.development b/.env.development index 2ca7d83..524dc5b 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,2 @@ -# 开发环境 — 请求走 vite proxy,前缀 /api 会被代理到 http://127.0.0.1:8080/api +# 开发环境 — 请求走 vite proxy VITE_API_BASE_URL=/api diff --git a/.env.production b/.env.production index e8908ba..0d362ab 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,2 @@ -# 生产环境 — 根据实际部署地址修改 +# 生产环境 — 同域路径代理,不需要完整域名 VITE_API_BASE_URL=/api diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24b496d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# ==================== 第一阶段:构建 ==================== +FROM node:20-alpine AS builder +WORKDIR /build + +# 安装 pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# 先拷贝依赖声明,利用 Docker 层缓存 +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +# 拷贝源码并构建 +COPY . . +RUN pnpm build + +# ==================== 第二阶段:Nginx serve 静态文件 ==================== +FROM nginx:alpine + +ENV TZ=Asia/Shanghai +RUN ln -sf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 拷贝构建产物 +COPY --from=builder /build/dist /usr/share/nginx/html + +# SPA history mode 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget --spider -q http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..c03615a --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,208 @@ +/** + * OfferPie Web 前端蓝绿部署流水线 + * + * 工作目录说明: + * - Jenkins 会自动为每个项目创建独立的工作空间:/var/jenkins_home/workspace/<项目名>/ + * - docker-compose.yml、Dockerfile、nginx.conf 都在项目根目录,直接执行即可 + * - nginx.conf 首次部署时 docker cp 进 nginx 容器 + * + * 内层 Nginx 职责: + * - 蓝绿切换(前端静态文件 serve) + * - /api/ 代理到 Java 后端(10202) + * - /ai-api/ 代理到 Python AI(10502),rewrite 去掉前缀 + */ +pipeline { + agent any + + parameters { + choice(name: 'BRANCH', choices: ['master', 'pre', 'dev', 'test'], description: '选择要部署的分支') + } + + environment { + CONTAINER_PREFIX = 'offerpie-web' // 容器名前缀,拼接 -blue / -green / -nginx + HEALTH_URL = 'http://localhost/' // 前端静态页面健康检查 + } + + stages { + stage('开始提示') { + steps { + echo "OfferPie Web 前端开始构建" + } + } + + stage('拉取代码') { + steps { + echo "拉取 ${params.BRANCH} 分支代码" + git branch: "${params.BRANCH}", + credentialsId: 'ef5fffc1-9b35-403d-9ca6-e1b73eb0e45a', + url: 'https://codeup.aliyun.com/5f0ed3b9769820a3e817dee2/offerpie/offerpie_web.git' + } + } + + /** + * 检测部署目标 + * - 检查 blue 和 green 容器的运行状态 + * - blue 在运行 → 部署 green;green 在运行 → 部署 blue + * - 都不在或都在 → 默认部署 blue + */ + stage('检测部署目标') { + steps { + echo "检查当前容器状态" + script { + def blueRunning = sh(script: "docker ps -q -f name=${CONTAINER_PREFIX}-blue", returnStdout: true).trim() + def greenRunning = sh(script: "docker ps -q -f name=${CONTAINER_PREFIX}-green", returnStdout: true).trim() + + env.DEPLOY_TARGET = '' + + if (blueRunning && !greenRunning) { + env.DEPLOY_TARGET = 'green' + } + if (greenRunning && !blueRunning) { + env.DEPLOY_TARGET = 'blue' + } + if (!env.DEPLOY_TARGET) { + echo "当前环境未部署服务或状态异常,默认部署 blue" + env.DEPLOY_TARGET = 'blue' + } + + env.OTHER_TARGET = (env.DEPLOY_TARGET == 'blue') ? 'green' : 'blue' + echo "当前激活: ${env.OTHER_TARGET},即将部署: ${env.DEPLOY_TARGET}" + } + } + } + + /** + * 构建新版本 + * - 先清理目标颜色的旧容器(如果残留) + * - docker-compose build 构建新镜像(含 pnpm build) + */ + stage('构建新版本') { + steps { + script { + def existingContainer = sh(script: "docker ps -aq -f name=${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}", returnStdout: true).trim() + if (existingContainer) { + echo "清理已存在的容器: ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}" + sh "docker rm -f ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}" + } + sh "docker-compose build ${env.DEPLOY_TARGET}" + } + } + } + + /** + * 检查 Nginx + * - 检查 nginx 容器是否存在 + * - 不存在则启动并复制配置文件(首次部署) + * - 存在则确保容器运行中 + */ + stage('检查Nginx') { + steps { + script { + def nginxExists = sh(script: "docker ps -aq -f name=${CONTAINER_PREFIX}-nginx", returnStdout: true).trim() + if (!nginxExists) { + echo "首次部署,初始化 Nginx 容器" + sh "docker-compose up -d nginx" + sh "sleep 3" + // 复制配置文件到容器内 + sh "docker cp proxy_nginx.conf ${CONTAINER_PREFIX}-nginx:/etc/nginx/nginx.conf" + sh "docker exec ${CONTAINER_PREFIX}-nginx nginx -s reload" + } else { + def nginxRunning = sh(script: "docker ps -q -f name=${CONTAINER_PREFIX}-nginx", returnStdout: true).trim() + if (!nginxRunning) { + echo "Nginx 容器已停止,重新启动" + sh "docker start ${CONTAINER_PREFIX}-nginx" + sh "sleep 2" + } + echo "Nginx 容器已存在且运行中" + } + } + } + } + + stage('启动新版本') { + steps { + script { + sh "docker-compose up -d ${env.DEPLOY_TARGET}" + // 等待 Nginx 容器启动 + sh 'sleep 5' + } + } + } + + /** + * 健康检查 + * - 进入容器检测静态页面是否正常返回 + * - 最多重试 3 次,每次间隔 5 秒 + * - 全部失败则终止部署流程 + */ + stage('健康检查') { + steps { + script { + def maxRetries = 3 + def retryCount = 0 + def healthy = false + + while (retryCount < maxRetries && !healthy) { + try { + sh "docker exec ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} wget --spider -q ${HEALTH_URL}" + healthy = true + } catch (Exception e) { + retryCount++ + if (retryCount < maxRetries) { + echo "健康检查失败,5秒后重试 (${retryCount}/${maxRetries})" + sleep 5 + } + } + } + + if (!healthy) { + error "健康检查失败,部署中止" + } + echo "✅ ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} 健康检查通过" + } + } + } + + /** + * 切换流量 + * - 修改 nginx 配置中的 proxy_pass 指向新版本 + * - nginx 不挂载宿主机文件,直接 sed -i 修改容器内配置 + * - reload 使新配置生效,实现零停机切换 + */ + stage('切换流量') { + steps { + script { + sh "docker exec ${CONTAINER_PREFIX}-nginx sed -i 's/proxy_pass http:\\/\\/\\(blue\\|green\\);/proxy_pass http:\\/\\/${env.DEPLOY_TARGET};/' /etc/nginx/nginx.conf" + sh "docker exec ${CONTAINER_PREFIX}-nginx nginx -s reload" + echo "✅ 流量已切换到 ${env.DEPLOY_TARGET}" + } + } + } + + /** + * 停止并删除旧版本 + * - 不能用 docker-compose down,会删除所有服务(包括 nginx) + * - 用 docker rm -f 只删除指定的旧颜色容器 + */ + stage('删除旧版本') { + steps { + script { + def otherExists = sh(script: "docker ps -aq -f name=${CONTAINER_PREFIX}-${env.OTHER_TARGET}", returnStdout: true).trim() + if (otherExists) { + sh "docker rm -f ${CONTAINER_PREFIX}-${env.OTHER_TARGET}" + echo "旧版本 ${env.OTHER_TARGET} 已删除" + } + } + } + } + } + + post { + success { + echo "✅ 蓝绿部署成功!当前运行: ${env.DEPLOY_TARGET}" + } + failure { + echo '❌ 部署失败,请检查日志' + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c0320c4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + nginx: + image: nginx:alpine + container_name: offerpie-web-nginx + restart: unless-stopped + ports: + - "10302:80" + depends_on: + - blue + - green + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' + + blue: + build: . + container_name: offerpie-web-blue + restart: unless-stopped + expose: + - "80" + environment: + - APP_VERSION=blue + - TZ=Asia/Shanghai + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' + + green: + build: . + container_name: offerpie-web-green + restart: unless-stopped + expose: + - "80" + environment: + - APP_VERSION=green + - TZ=Asia/Shanghai + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..92b1c06 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_vary on; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript image/svg+xml; + + # 静态资源缓存(带 hash 的文件) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA history mode:所有路由 fallback 到 index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/proxy_nginx.conf b/proxy_nginx.conf new file mode 100644 index 0000000..0f4cb43 --- /dev/null +++ b/proxy_nginx.conf @@ -0,0 +1,91 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + sendfile on; + keepalive_timeout 65; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + + # 请求体大小限制 + client_max_body_size 20m; + + # 蓝绿 upstream + upstream blue { + server offerpie-web-blue:80; + } + + upstream green { + server offerpie-web-green:80; + } + + server { + listen 80; + server_name _; + + # Java 后端 API 代理 + location /api/ { + proxy_pass http://8.138.5.14:10202/api/; + proxy_http_version 1.1; + proxy_set_header Host $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; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # Python AI 代理(去掉 /ai-api 前缀) + location /ai-api/ { + proxy_pass http://8.138.5.14:10502/; + proxy_http_version 1.1; + proxy_set_header Host $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; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 流式输出支持 + proxy_buffering off; + proxy_cache off; + + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # 前端静态文件(蓝绿切换,默认 blue,部署时 sed 切换) + location / { + proxy_pass http://blue; + proxy_http_version 1.1; + proxy_set_header Host $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; + } + + # Nginx 自身健康检查 + location /health { + access_log off; + return 200 "ok"; + } + } +}