From 14590c5edab1da44cb0110299c72d8ca21cf8c14 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 18:04:02 +0800 Subject: [PATCH] ci: align frontend deploy with wx_service secrets-driven flow --- .env.production | 5 +- .github/workflows/deploy-prod.yml | 105 ++++++++++++++++++++++++++ README.md | 17 +++++ docs/deploy_ci.md | 74 ++++++++++++++++++ scripts/ops/deploy_static.sh | 121 ++++++++++++++++++++++++++++++ src/utils/request.js | 6 +- vite.config.js | 32 +++++--- 7 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/deploy-prod.yml create mode 100644 docs/deploy_ci.md create mode 100755 scripts/ops/deploy_static.sh diff --git a/.env.production b/.env.production index bf91513..9f59b63 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,5 @@ -# 生产环境(前后端同域部署时使用相对路径) +# 生产环境 API(前后端同域部署时使用相对路径) VITE_API_BASE_URL=/ + +# 生产环境前端路由基路径 +VITE_PUBLIC_BASE=/panel/ diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..e4f0551 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,105 @@ +name: deploy-admin-frontend-prod + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: admin-frontend-prod + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Package dist + run: | + set -e + tar -czf "/tmp/admin-frontend-${GITHUB_SHA}.tar.gz" -C dist . + + - name: Validate required secrets + env: + PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + PROD_HOST: ${{ secrets.PROD_HOST }} + PROD_PORT: ${{ secrets.PROD_PORT }} + PROD_USER: ${{ secrets.PROD_USER }} + ADMIN_WEB_ROOT: ${{ secrets.ADMIN_WEB_ROOT }} + ADMIN_WEB_USER: ${{ secrets.ADMIN_WEB_USER }} + ADMIN_WEB_GROUP: ${{ secrets.ADMIN_WEB_GROUP }} + ADMIN_KEEP_BACKUPS: ${{ secrets.ADMIN_KEEP_BACKUPS }} + run: | + set -e + for key in PROD_SSH_KEY PROD_HOST PROD_PORT PROD_USER ADMIN_WEB_ROOT ADMIN_WEB_USER ADMIN_WEB_GROUP ADMIN_KEEP_BACKUPS; do + if [ -z "${!key}" ]; then + echo "Missing required secret: ${key}" + exit 1 + fi + done + + - name: Prepare SSH + env: + SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + HOST: ${{ secrets.PROD_HOST }} + PORT: ${{ secrets.PROD_PORT }} + run: | + set -e + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -p "$PORT" "$HOST" >> ~/.ssh/known_hosts + + - name: Upload build artifact to server + env: + HOST: ${{ secrets.PROD_HOST }} + PORT: ${{ secrets.PROD_PORT }} + USER: ${{ secrets.PROD_USER }} + run: | + set -e + SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=10 -o ServerAliveCountMax=6" + scp ${SSH_OPTS} -P "$PORT" \ + "/tmp/admin-frontend-${GITHUB_SHA}.tar.gz" \ + "${USER}@${HOST}:/tmp/admin-frontend-${GITHUB_SHA}.tar.gz" + + - name: Deploy static files on server + env: + HOST: ${{ secrets.PROD_HOST }} + PORT: ${{ secrets.PROD_PORT }} + USER: ${{ secrets.PROD_USER }} + WEB_ROOT: ${{ secrets.ADMIN_WEB_ROOT }} + WEB_USER: ${{ secrets.ADMIN_WEB_USER }} + WEB_GROUP: ${{ secrets.ADMIN_WEB_GROUP }} + KEEP_BACKUPS: ${{ secrets.ADMIN_KEEP_BACKUPS }} + HEALTHCHECK_URL: ${{ secrets.ADMIN_HEALTHCHECK_URL }} + run: | + set -e + SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=10 -o ServerAliveCountMax=6" + + ssh ${SSH_OPTS} -p "$PORT" "${USER}@${HOST}" \ + "DEPLOY_TAR='/tmp/admin-frontend-${GITHUB_SHA}.tar.gz' \ + TARGET_DIR='${WEB_ROOT}' \ + RELEASE_ID='${GITHUB_SHA}' \ + RUN_USER='${WEB_USER}' \ + RUN_GROUP='${WEB_GROUP}' \ + KEEP_BACKUPS='${KEEP_BACKUPS}' \ + HEALTHCHECK_URL='${HEALTHCHECK_URL}' \ + bash -s" < scripts/ops/deploy_static.sh diff --git a/README.md b/README.md index 46444d1..b927d42 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,20 @@ npm run build - 会员、保质期、去水印、系统设置接口联调 - 会员管理的记录详情与批量操作 - 系统设置中的操作日志查询 + +## 自动构建与发布(GitHub Actions) + +已新增生产自动发布流程: +- 工作流:`.github/workflows/deploy-prod.yml` +- 远程发布脚本:`scripts/ops/deploy_static.sh` +- 详细说明:`docs/deploy_ci.md` + +部署参数全部走 GitHub Secrets(不使用脚本默认值),并与 `wx_service` 对齐: +- `PROD_HOST=115.159.198.14` +- `PROD_PORT=22` +- `PROD_USER=root` +- `ADMIN_WEB_ROOT=/www/wwwroot/wx_service/web/admin-frontend/panel` + +密钥说明: +- `~/.ssh/id_ed25519.pub` 是公钥(放服务器) +- GitHub Secret `PROD_SSH_KEY` 需填写私钥 `~/.ssh/id_ed25519` diff --git a/docs/deploy_ci.md b/docs/deploy_ci.md new file mode 100644 index 0000000..4c7381c --- /dev/null +++ b/docs/deploy_ci.md @@ -0,0 +1,74 @@ +# 生产部署(GitHub Actions,前端静态站点) + +本文档用于 `admin-frontend` 的自动化构建发布: +- 触发:`main` 分支 push 或手动触发 +- 流程:GitHub Actions 构建 `dist` -> 通过 SSH 上传到服务器 -> 远程脚本覆盖发布 +- 特点:参考 `wx_service` 部署流程,全部参数由 GitHub Secrets 驱动 + +## 0. 与 wx_service 对齐 + +- 使用同一台服务器:`115.159.198.14` +- SSH 用户:`root` +- 可复用 `wx_service` 的 SSH 密钥体系 +- 不使用脚本内默认服务器参数,统一由仓库 Secrets 注入 + +## 1. Nginx 路径约定(当前配置) + +```nginx +location ^~ /panel/ { + root /www/wwwroot/wx_service/web/admin-frontend; + index index.html; + try_files $uri $uri/ /panel/index.html; +} +``` + +对应发布目录: +- `/www/wwwroot/wx_service/web/admin-frontend/panel` + +## 2. 必填 GitHub Secrets + +在 `admin-frontend` 仓库 `Settings -> Secrets and variables -> Actions` 配置: + +- `PROD_SSH_KEY`:私钥内容(`~/.ssh/id_ed25519`) +- `PROD_HOST`:`115.159.198.14` +- `PROD_PORT`:`22` +- `PROD_USER`:`root` +- `ADMIN_WEB_ROOT`:`/www/wwwroot/wx_service/web/admin-frontend/panel` +- `ADMIN_WEB_USER`:`www` +- `ADMIN_WEB_GROUP`:`www` +- `ADMIN_KEEP_BACKUPS`:`5` + +可选: +- `ADMIN_HEALTHCHECK_URL`:例如 `http://115.159.198.14/panel/` + +## 3. 密钥说明 + +`~/.ssh/id_ed25519.pub` 是公钥,用于写入服务器 `~/.ssh/authorized_keys`。 + +GitHub Actions 里的 `PROD_SSH_KEY` 必须填写私钥: +- `~/.ssh/id_ed25519` + +## 4. 工作流文件 + +- `/.github/workflows/deploy-prod.yml` + +每次主分支变更会自动: +1. `npm ci` +2. `npm run build`(生产基路径 `/panel/`) +3. 上传构建包到服务器 `/tmp` +4. 调用 `scripts/ops/deploy_static.sh` 覆盖发布 + +## 5. 回滚 + +在服务器执行: + +```bash +ls -lt /www/wwwroot/wx_service/web/admin-frontend/backups/admin-frontend.*.tar.gz + +TARGET_DIR=/www/wwwroot/wx_service/web/admin-frontend/panel +BACKUP_FILE=/www/wwwroot/wx_service/web/admin-frontend/backups/admin-frontend..tar.gz + +find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + +tar -xzf "$BACKUP_FILE" -C "$TARGET_DIR" +chown -R www:www "$TARGET_DIR" +``` diff --git a/scripts/ops/deploy_static.sh b/scripts/ops/deploy_static.sh new file mode 100755 index 0000000..30c8944 --- /dev/null +++ b/scripts/ops/deploy_static.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# 待发布的构建产物(tar.gz),由 CI 上传到服务器后传入 +DEPLOY_TAR="${DEPLOY_TAR:-}" +# 静态站点目录(与 Nginx location /panel/ 对齐) +TARGET_DIR="${TARGET_DIR:-}" +# 发布标识(通常用 commit sha) +RELEASE_ID="${RELEASE_ID:-}" +# 文件归属用户/组 +RUN_USER="${RUN_USER:-}" +RUN_GROUP="${RUN_GROUP:-}" +# 备份保留数量 +KEEP_BACKUPS="${KEEP_BACKUPS:-}" +# 可选:发布后探活地址,例如 http://example.com/panel/ +HEALTHCHECK_URL="${HEALTHCHECK_URL:-}" + +BACKUP_ROOT="${BACKUP_ROOT:-$(dirname "$TARGET_DIR")/backups}" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +require_env() { + local name="$1" + local value="$2" + if [ -z "$value" ]; then + echo "missing required env: $name" >&2 + exit 1 + fi +} + +health_check() { + if [ -z "$HEALTHCHECK_URL" ]; then + return 0 + fi + + if ! command -v curl >/dev/null 2>&1; then + log "curl 不存在,跳过 HEALTHCHECK_URL 检查" + return 0 + fi + + local tries=15 + while [ "$tries" -gt 0 ]; do + if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then + return 0 + fi + tries=$((tries - 1)) + sleep 2 + done + + return 1 +} + +require_env "DEPLOY_TAR" "$DEPLOY_TAR" +require_env "TARGET_DIR" "$TARGET_DIR" +require_env "RELEASE_ID" "$RELEASE_ID" +require_env "RUN_USER" "$RUN_USER" +require_env "RUN_GROUP" "$RUN_GROUP" +require_env "KEEP_BACKUPS" "$KEEP_BACKUPS" + +if [ ! -f "$DEPLOY_TAR" ]; then + echo "deploy tar not found: $DEPLOY_TAR" >&2 + exit 1 +fi + +tmp_dir="/tmp/admin-frontend-${RELEASE_ID:0:12}-$$" +backup_file="" + +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +log "deploy start, release: ${RELEASE_ID}" +mkdir -p "$tmp_dir" "$TARGET_DIR" "$BACKUP_ROOT" + +log "extracting artifact" +tar -xzf "$DEPLOY_TAR" -C "$tmp_dir" + +if [ ! -f "$tmp_dir/index.html" ]; then + echo "invalid artifact: index.html not found" >&2 + exit 1 +fi + +# 发布前备份当前站点,支持快速回滚 +if [ -n "$(ls -A "$TARGET_DIR" 2>/dev/null)" ]; then + backup_file="$BACKUP_ROOT/admin-frontend.${RELEASE_ID}.tar.gz" + tar -czf "$backup_file" -C "$TARGET_DIR" . + log "backup created: $backup_file" +fi + +# 覆盖发布:先清空目录再写入新构建 +find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + +tar -cf - -C "$tmp_dir" . | tar -xf - -C "$TARGET_DIR" +chown -R "$RUN_USER:$RUN_GROUP" "$TARGET_DIR" || true + +if health_check; then + if [ -n "$HEALTHCHECK_URL" ]; then + log "health check success: $HEALTHCHECK_URL" + fi +else + log "health check failed, rolling back" + if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then + find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + tar -xzf "$backup_file" -C "$TARGET_DIR" + chown -R "$RUN_USER:$RUN_GROUP" "$TARGET_DIR" || true + log "rollback done" + fi + exit 1 +fi + +# 清理上传到 /tmp 的临时制品 +if [[ "$DEPLOY_TAR" == /tmp/* ]]; then + rm -f "$DEPLOY_TAR" +fi + +# 清理旧备份,仅保留最近 KEEP_BACKUPS 份 +ls -1t "$BACKUP_ROOT"/admin-frontend.*.tar.gz 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f + +log "deploy done" diff --git a/src/utils/request.js b/src/utils/request.js index 0dc0afe..52f162e 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -1,6 +1,8 @@ import axios from 'axios' import { ElMessage } from 'element-plus' +const loginPath = `${import.meta.env.BASE_URL}login` + // 创建 axios 实例 const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/', @@ -33,7 +35,7 @@ request.interceptors.response.use( // 401: 未登录 if (res.code === 401) { localStorage.removeItem('admin_token') - window.location.href = '/login' + window.location.href = loginPath } return Promise.reject(new Error(res.message || '请求失败')) @@ -50,7 +52,7 @@ request.interceptors.response.use( if (status === 401) { ElMessage.error('登录已过期,请重新登录') localStorage.removeItem('admin_token') - window.location.href = '/login' + window.location.href = loginPath } else if (status === 403) { ElMessage.error('没有权限访问') } else if (status === 404) { diff --git a/vite.config.js b/vite.config.js index 9781c73..0e81544 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,18 +1,26 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' // https://vite.dev/config/ -export default defineConfig({ - plugins: [vue()], - server: { - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true - }, - '/healthz': { - target: 'http://localhost:8080', - changeOrigin: true +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + // 生产默认挂载在 /panel/,可通过 VITE_PUBLIC_BASE 覆盖 + const base = env.VITE_PUBLIC_BASE || (mode === 'production' ? '/panel/' : '/') + + return { + base, + plugins: [vue()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + }, + '/healthz': { + target: 'http://localhost:8080', + changeOrigin: true + } } } }