diff --git a/.gitea/workflows/preview-cleanup.yml b/.gitea/workflows/preview-cleanup.yml new file mode 100644 index 000000000..ba32e02ee --- /dev/null +++ b/.gitea/workflows/preview-cleanup.yml @@ -0,0 +1,118 @@ +name: Preview Cleanup +on: + pull_request: + types: + - closed + branches: + - main + - develop +permissions: + contents: read + pull-requests: write +jobs: + cleanup-preview: + name: Cleanup Preview Environment + runs-on: runtime-builder + timeout-minutes: 10 + steps: + - name: Extract PR number + shell: sh + run: | + set -eu + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV + echo "PR number: $PR_NUMBER" + echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}" + + - name: Install SSH client + shell: sh + run: | + set -eu + apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1 + echo "openssh-client installed" + + - name: Remove preview directory from server + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + set -eux + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_user="${STAGING_SSH_USER:-root}" + staging_port="${STAGING_SSH_PORT:-22222}" + preview_dir="/var/www/preview/pr-${PR_NUMBER}" + + mkdir -p ~/.ssh + + # 查找可用的SSH密钥 + key_path="" + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + echo "Using key: $key_path (builder key)" + elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then + key_path="$HOME/.ssh/xiaoxia_runtime_builder" + echo "Using key: $key_path (home key)" + elif [ -n "${STAGING_SSH_KEY:-}" ]; then + key_path="$HOME/.ssh/id_ed25519" + printf '%s\n' "$STAGING_SSH_KEY" > "$key_path" + chmod 600 "$key_path" + echo "Using key from STAGING_SSH_KEY secret" + else + echo "ERROR: No SSH key available" + ls -la ~/.ssh/ 2>/dev/null || true + ls -la /root/.ssh/ 2>/dev/null || true + exit 1 + fi + + ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null + echo "SSH keyscan done" + + # 测试SSH连接 + ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "echo SSH_CONNECTION_OK && hostname" + echo "SSH connection verified" + + # 检查目录是否存在 + DIR_EXISTS=$(ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \ + "if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi") + + if [ "$DIR_EXISTS" = "yes" ]; then + echo "Removing preview directory: ${preview_dir}" + ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \ + "rm -rf ${preview_dir} && echo 'Preview directory removed successfully'" + echo "Cleanup completed: ${preview_dir}" + else + echo "Preview directory does not exist: ${preview_dir}, nothing to clean up" + fi + + - name: Comment cleanup notice on PR + if: success() + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + + COMMENT_BODY=$(python3 -c " + import json, os + pr_num = os.environ['PR_NUMBER'] + body = f'''🗑️ **预览环境已清理** + +PR #{pr_num} 已关闭或合并,对应的预览环境已被清理。 + +> 如有需要,可以重新打开 PR 来重新生成预览环境。 +''' + print(json.dumps({'body': body})) + ") + + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" + curl -s -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$COMMENT_BODY" \ + "$API_URL" \ + > /dev/null + echo "Cleanup comment posted" diff --git a/.gitea/workflows/preview-deploy.yml b/.gitea/workflows/preview-deploy.yml new file mode 100644 index 000000000..abaf864be --- /dev/null +++ b/.gitea/workflows/preview-deploy.yml @@ -0,0 +1,293 @@ +name: Preview Deploy +on: + pull_request: + types: + - opened + - synchronize + - reopened + branches: + - main + - develop + workflow_dispatch: + inputs: + reason: + description: "触发原因" + required: false + default: "手动触发 - 预览环境补跑" +permissions: + contents: read + pull-requests: write +concurrency: + group: preview-deploy-${{ gitea.ref }} + cancel-in-progress: true +jobs: + deploy-preview: + name: Deploy Preview Environment + runs-on: runtime-builder + timeout-minutes: 20 + steps: + - name: Checkout code + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + python3 - <<'PY' + import io, os, tarfile, time, urllib.request, urllib.error + url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz" + request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"}) + last_err = None + for attempt in range(5): + try: + with urllib.request.urlopen(request, timeout=120) as response: + archive = response.read() + break + except urllib.error.HTTPError as e: + last_err = e + if e.code >= 500 and attempt < 4: + wait = 2 ** attempt + print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + except Exception as e: + last_err = e + if attempt < 4: + wait = 2 ** attempt + print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...") + time.sleep(wait) + continue + raise + else: + raise last_err + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/' + for member in tar.getmembers(): + name = member.name + if name == root_prefix[:-1]: + continue + if name.startswith(root_prefix): + member.name = name[len(root_prefix):] + if member.name: + tar.extract(member, '.') + PY + + - name: Record job start time + shell: sh + run: | + set -eu + echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV + echo "Job started at $(date)" + + - name: Extract PR number + shell: sh + run: | + set -eu + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV + echo "PR number: $PR_NUMBER" + echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV + echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" + + - name: Build frontend + shell: sh + run: | + set -eu + NPM_CACHE_VOLUME="xiaoxia-npm-cache" + if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then + docker volume create "$NPM_CACHE_VOLUME" >/dev/null + echo "Created npm cache volume: $NPM_CACHE_VOLUME" + fi + + docker run --rm \ + -v "$PWD:/workspace" \ + -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \ + -w /workspace/apps/web \ + -e VITE_API_URL=https://staging-api.xiaoxiajianji.com \ + docker.m.daocloud.io/library/node:20 \ + sh -lc ' + PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1) + CACHE_HASH_FILE="node_modules/.package-lock-hash" + CACHE_VALID=false + if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then + CACHE_VALID=true + echo "Cache hit: dependencies valid, skipping npm ci" + fi + if [ "$CACHE_VALID" = "false" ]; then + echo "Cache miss or invalid: running npm ci..." + if ! npm ci --include=dev; then + echo "npm ci failed, cleaning node_modules and retrying..." + rm -rf node_modules + mkdir -p node_modules + npm ci --include=dev + fi + echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE" + echo "Dependencies installed, cache updated" + fi + echo "Running TypeScript check..." + npx --no-install tsc + echo "Running Vite build..." + npx --no-install vite build + echo "Build completed successfully" + ls -la dist/ + ' + + - name: Install SSH client + shell: sh + run: | + set -eu + apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1 + echo "openssh-client and rsync installed" + + - name: Deploy preview to server + shell: sh + env: + STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }} + STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }} + STAGING_SSH_PORT: ${{ secrets.STAGING_SSH_PORT }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + set -eux + staging_host="${STAGING_SSH_HOST:-47.98.113.167}" + staging_user="${STAGING_SSH_USER:-root}" + staging_port="${STAGING_SSH_PORT:-22222}" + preview_dir="/var/www/preview/pr-${PR_NUMBER}" + + mkdir -p ~/.ssh + + # 查找可用的SSH密钥 + key_path="" + if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then + key_path="/root/.ssh/xiaoxia_runtime_builder" + echo "Using key: $key_path (builder key)" + elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then + key_path="$HOME/.ssh/xiaoxia_runtime_builder" + echo "Using key: $key_path (home key)" + elif [ -n "${STAGING_SSH_KEY:-}" ]; then + key_path="$HOME/.ssh/id_ed25519" + printf '%s\n' "$STAGING_SSH_KEY" > "$key_path" + chmod 600 "$key_path" + echo "Using key from STAGING_SSH_KEY secret" + else + echo "ERROR: No SSH key available" + ls -la ~/.ssh/ 2>/dev/null || true + ls -la /root/.ssh/ 2>/dev/null || true + exit 1 + fi + + ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null + echo "SSH keyscan done" + + # 测试SSH连接 + ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "echo SSH_CONNECTION_OK && hostname" + echo "SSH connection verified" + + # 创建预览目录并上传文件 + ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \ + "mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'" + + # 使用rsync上传dist目录内容 + rsync -avz --delete -e "ssh -p ${staging_port} -i ${key_path} -o StrictHostKeyChecking=no" \ + apps/web/dist/ \ + "${staging_user}@${staging_host}:${preview_dir}/" + + echo "Preview deployed to: ${preview_dir}" + echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" + + - name: Comment preview link on PR + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -eu + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" + + # 构建评论内容 + COMMENT_BODY=$(python3 -c " + import json, os + pr_num = os.environ['PR_NUMBER'] + url = os.environ['PREVIEW_URL'] + body = f'''🚀 **预览环境已部署** + +| 项目 | 详情 | +|------|------| +| PR号 | #{pr_num} | +| 预览链接 | [${url}](${url}) | +| API环境 | staging | + +> 💡 预览环境使用 staging API 数据,请勿在预览环境中操作重要数据。 +> +> 🔄 每次提交新代码后预览环境会自动更新。 +> +> 🗑️ PR 关闭或合并后,预览环境会自动清理。 +''' + print(json.dumps({'body': body})) + ") + + # 查找是否已有预览评论 + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" + EXISTING_COMMENT_ID="" + + COMMENTS=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL") + EXISTING_COMMENT_ID=$(echo "$COMMENTS" | python3 -c " + import sys, json + try: + comments = json.load(sys.stdin) + for c in comments: + body = c.get('body', '') + if '预览环境已部署' in body and 'Preview URL' not in body: + print(c['id']) + break + except: + print('') + ") + + if [ -n "$EXISTING_COMMENT_ID" ]; then + # 更新已有评论 + echo "Updating existing comment: $EXISTING_COMMENT_ID" + curl -s -X PATCH \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$COMMENT_BODY" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \ + > /dev/null + echo "Comment updated" + else + # 发布新评论 + echo "Creating new comment" + curl -s -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$COMMENT_BODY" \ + "$API_URL" \ + > /dev/null + echo "Comment posted" + fi + + - name: Job duration summary + if: always() + shell: sh + run: | + set +eu + if [ -n "$JOB_START_TIME" ]; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - JOB_START_TIME)) + MINS=$((DURATION / 60)) + SECS=$((DURATION % 60)) + echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV + echo "=== Job Duration: ${MINS}m${SECS}s ===" + else + echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV + echo "=== Job Duration: unknown ===" + fi + + - name: Notify on failure + continue-on-error: true + if: failure() + shell: sh + env: + CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }} + run: | + set +e + NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py diff --git a/scripts/ci/preview_init_server.sh b/scripts/ci/preview_init_server.sh new file mode 100644 index 000000000..2091882e0 --- /dev/null +++ b/scripts/ci/preview_init_server.sh @@ -0,0 +1,264 @@ +#!/bin/bash +# ============================================================ +# 预览环境服务器初始化脚本 +# 用途:在业务服务器上创建预览环境所需的目录和配置 +# 使用方式:bash scripts/ci/preview_init_server.sh +# ============================================================ + +set -eu + +PREVIEW_ROOT="/var/www/preview" +NGINX_CONF_PATH="/etc/nginx/conf.d/preview.conf" +DOMAIN="xiaoxiajianji.com" +STAGING_API="https://staging-api.xiaoxiajianji.com" + +echo "==========================================" +echo " 预览环境服务器初始化" +echo "==========================================" +echo "" + +# 1. 创建预览根目录 +echo "[1/4] 创建预览根目录..." +if [ -d "$PREVIEW_ROOT" ]; then + echo " 目录已存在: $PREVIEW_ROOT" +else + mkdir -p "$PREVIEW_ROOT" + echo " 已创建: $PREVIEW_ROOT" +fi +chown -R root:root "$PREVIEW_ROOT" +chmod -R 755 "$PREVIEW_ROOT" +echo "" + +# 2. 创建测试页面(验证Nginx配置用) +echo "[2/4] 创建测试页面..." +TEST_DIR="${PREVIEW_ROOT}/pr-demo" +mkdir -p "$TEST_DIR" +cat > "$TEST_DIR/index.html" <<'EOF' + + + + + + 预览环境测试页 + + + +
+
+

预览环境配置成功!

+

如果你能看到这个页面,说明 Nginx 预览环境配置正确。

+

当前站点通过子域名 pr-demo.preview 路由到 /var/www/preview/pr-demo/ 目录。

+
+ + +EOF +echo " 测试页面已创建: $TEST_DIR/index.html" +echo "" + +# 3. 检查Nginx是否安装 +echo "[3/4] 检查Nginx环境..." +if command -v nginx > /dev/null 2>&1; then + echo " Nginx 已安装: $(nginx -v 2>&1)" + NGINX_INSTALLED=true +else + echo " ⚠️ Nginx 未安装,请先安装 Nginx" + NGINX_INSTALLED=false +fi +echo "" + +# 4. 输出Nginx配置建议 +echo "[4/4] Nginx 配置建议" +echo "" +echo "----------------------------------------" +echo " 请将以下配置保存到: $NGINX_CONF_PATH" +echo " 或复制到 Nginx 配置目录中" +echo "----------------------------------------" +echo "" + +cat <<'NGINX_CONF' +# ============================================================ +# 预览环境 Nginx 配置 +# 支持 *.preview.xiaoxiajianji.com 通配符子域名 +# ============================================================ + +# 从子域名中提取 PR 号(如 pr-123.preview -> pr-123) +map $host $preview_pr { + default ""; + ~^(?pr-\d+)\.preview\.xiaoxiajianji\.com$ $pr; +} + +# HTTP 服务器(80端口) +server { + listen 80; + server_name *.preview.xiaoxiajianji.com; + + # 根目录根据子域名动态映射 + root /var/www/preview/$preview_pr; + + # 索引文件 + index index.html; + + # 字符集 + charset utf-8; + + # 访问日志 + access_log /var/log/nginx/preview_access.log; + error_log /var/log/nginx/preview_error.log warn; + + # 如果子域名格式不正确,返回404 + if ($preview_pr = "") { + return 404; + } + + # 如果预览目录不存在,返回404 + if (!-d $document_root) { + return 404; + } + + # API 反向代理到 staging 环境 + location /api/ { + proxy_pass https://staging-api.xiaoxiajianji.com/api/; + proxy_http_version 1.1; + proxy_set_header Host staging-api.xiaoxiajianji.com; + 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 X-Forwarded-Host $host; + + # 超时设置 + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # 缓冲设置 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # WebSocket 支持(如需要) + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + } + + # 静态资源缓存 + location /assets/ { + expires 7d; + add_header Cache-Control "public, max-age=604800, immutable"; + try_files $uri =404; + } + + # SPA 路由支持 + location / { + try_files $uri $uri/ /index.html; + } + + # 安全相关响应头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # 禁止隐藏文件访问 + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} + +# HTTPS 服务器(443端口) +# 注意:需要先配置 SSL 证书 +# 建议使用 certbot 或手动配置证书 +# +# server { +# listen 443 ssl http2; +# server_name *.preview.xiaoxiajianji.com; +# +# # SSL 证书配置(请替换为实际证书路径) +# ssl_certificate /path/to/fullchain.pem; +# ssl_certificate_key /path/to/privkey.pem; +# +# # SSL 安全配置 +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers HIGH:!aNULL:!MD5; +# ssl_prefer_server_ciphers on; +# ssl_session_cache shared:SSL:10m; +# ssl_session_timeout 10m; +# +# # 其余配置与 HTTP 相同 +# root /var/www/preview/$preview_pr; +# index index.html; +# charset utf-8; +# +# access_log /var/log/nginx/preview_ssl_access.log; +# error_log /var/log/nginx/preview_ssl_error.log warn; +# +# if ($preview_pr = "") { +# return 404; +# } +# +# if (!-d $document_root) { +# return 404; +# } +# +# location /api/ { +# proxy_pass https://staging-api.xiaoxiajianji.com/api/; +# proxy_http_version 1.1; +# proxy_set_header Host staging-api.xiaoxiajianji.com; +# 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 X-Forwarded-Host $host; +# proxy_connect_timeout 30s; +# proxy_send_timeout 60s; +# proxy_read_timeout 60s; +# } +# +# location /assets/ { +# expires 7d; +# add_header Cache-Control "public, max-age=604800, immutable"; +# try_files $uri =404; +# } +# +# location / { +# try_files $uri $uri/ /index.html; +# } +# +# add_header X-Frame-Options "SAMEORIGIN" always; +# add_header X-Content-Type-Options "nosniff" always; +# add_header X-XSS-Protection "1; mode=block" always; +# +# location ~ /\. { +# deny all; +# access_log off; +# log_not_found off; +# } +# } +NGINX_CONF + +echo "" +echo "----------------------------------------" +echo " 配置完成后的操作步骤:" +echo "----------------------------------------" +echo "" +echo "1. 将上面的 Nginx 配置保存到合适的位置(如 /etc/nginx/conf.d/preview.conf)" +echo "2. 测试配置: nginx -t" +echo "3. 重载配置: nginx -s reload" +echo "4. 配置 DNS 解析: 将 *.preview.xiaoxiajianji.com 指向服务器 IP" +echo "5. 配置 SSL 证书(推荐使用 Let's Encrypt 通配符证书)" +echo "" +echo "测试方式:" +echo " 访问 http://pr-demo.preview.xiaoxiajianji.com 验证配置" +echo "" +echo "==========================================" +echo " 初始化完成" +echo "==========================================" diff --git a/scripts/ci/preview_nginx.conf.template b/scripts/ci/preview_nginx.conf.template new file mode 100644 index 000000000..2b420eda5 --- /dev/null +++ b/scripts/ci/preview_nginx.conf.template @@ -0,0 +1,226 @@ +# ============================================================ +# 预览环境 Nginx 配置模板 +# 支持 *.preview.xiaoxiajianji.com 通配符子域名 +# +# 使用方法: +# 1. 将本文件复制到 Nginx 配置目录(如 /etc/nginx/conf.d/preview.conf) +# 2. 根据实际情况修改域名和 API 地址 +# 3. 运行 nginx -t 测试配置 +# 4. 运行 nginx -s reload 重载配置 +# +# 前置条件: +# - DNS 已配置 *.preview.xiaoxiajianji.com 指向本服务器 +# - 预览根目录已创建:/var/www/preview/ +# - 每个 PR 的静态文件放在 /var/www/preview/pr-{N}/ 下 +# ============================================================ + +# ---- 变量定义 ---- +# 从子域名中提取 PR 号(如 pr-123.preview -> pr-123) +map $host $preview_pr { + default ""; + ~^(?pr-\d+)\.preview\.xiaoxiajianji\.com$ $pr; +} + +# ---- HTTP 服务器(80端口) ---- +server { + listen 80; + server_name *.preview.xiaoxiajianji.com; + + # 根目录根据子域名动态映射 + root /var/www/preview/$preview_pr; + + # 索引文件 + index index.html; + + # 字符集 + charset utf-8; + + # 访问日志 + access_log /var/log/nginx/preview_access.log; + error_log /var/log/nginx/preview_error.log warn; + + # 如果子域名格式不正确,返回404 + if ($preview_pr = "") { + return 404; + } + + # 如果预览目录不存在,返回404 + if (!-d $document_root) { + return 404; + } + + # ---- API 反向代理到 staging 环境 ---- + location /api/ { + proxy_pass https://staging-api.xiaoxiajianji.com/api/; + proxy_http_version 1.1; + + # 请求头设置 + proxy_set_header Host staging-api.xiaoxiajianji.com; + 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 X-Forwarded-Host $host; + + # 超时设置 + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # 缓冲设置 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # 重定向跟随 + proxy_redirect off; + + # WebSocket 支持(如需要,取消注释) + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + } + + # ---- 生成文件代理(如需要) ---- + # location /generated-files/ { + # proxy_pass https://staging-api.xiaoxiajianji.com/generated-files/; + # proxy_http_version 1.1; + # proxy_set_header Host staging-api.xiaoxiajianji.com; + # 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; + # } + + # ---- 静态资源缓存 ---- + location /assets/ { + expires 7d; + add_header Cache-Control "public, max-age=604800, immutable"; + try_files $uri =404; + } + + # ---- SPA 路由支持 ---- + location / { + try_files $uri $uri/ /index.html; + } + + # ---- 安全相关响应头 ---- + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # ---- 禁止隐藏文件访问 ---- + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + # ---- 禁止敏感文件访问 ---- + location ~* \.(env|log|sql|bak|swp|tmp|zip|tar|gz)$ { + deny all; + access_log off; + log_not_found off; + } +} + +# ============================================================ +# HTTPS 服务器配置(可选,需要 SSL 证书) +# +# 推荐使用 Let's Encrypt 通配符证书: +# certbot certonly --dns-xxx -d "*.preview.xiaoxiajianji.com" +# +# 启用方法:取消下方注释,并修改证书路径 +# ============================================================ +# +# server { +# listen 443 ssl http2; +# server_name *.preview.xiaoxiajianji.com; +# +# # SSL 证书配置 +# ssl_certificate /etc/letsencrypt/live/preview.xiaoxiajianji.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/preview.xiaoxiajianji.com/privkey.pem; +# +# # SSL 安全配置 +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; +# ssl_prefer_server_ciphers off; +# ssl_session_cache shared:SSL:10m; +# ssl_session_timeout 10m; +# ssl_session_tickets off; +# +# # OCSP Stapling +# ssl_stapling on; +# ssl_stapling_verify on; +# +# # 根目录根据子域名动态映射 +# root /var/www/preview/$preview_pr; +# +# # 索引文件 +# index index.html; +# +# # 字符集 +# charset utf-8; +# +# # 访问日志 +# access_log /var/log/nginx/preview_ssl_access.log; +# error_log /var/log/nginx/preview_ssl_error.log warn; +# +# # 如果子域名格式不正确,返回404 +# if ($preview_pr = "") { +# return 404; +# } +# +# # 如果预览目录不存在,返回404 +# if (!-d $document_root) { +# return 404; +# } +# +# # API 反向代理到 staging 环境 +# location /api/ { +# proxy_pass https://staging-api.xiaoxiajianji.com/api/; +# proxy_http_version 1.1; +# proxy_set_header Host staging-api.xiaoxiajianji.com; +# 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 X-Forwarded-Host $host; +# proxy_connect_timeout 30s; +# proxy_send_timeout 60s; +# proxy_read_timeout 60s; +# proxy_buffering on; +# proxy_buffer_size 4k; +# proxy_buffers 8 4k; +# } +# +# # 静态资源缓存 +# location /assets/ { +# expires 7d; +# add_header Cache-Control "public, max-age=604800, immutable"; +# try_files $uri =404; +# } +# +# # SPA 路由支持 +# location / { +# try_files $uri $uri/ /index.html; +# } +# +# # 安全相关响应头 +# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +# add_header X-Frame-Options "SAMEORIGIN" always; +# add_header X-Content-Type-Options "nosniff" always; +# add_header X-XSS-Protection "1; mode=block" always; +# add_header Referrer-Policy "strict-origin-when-cross-origin" always; +# +# # 禁止隐藏文件访问 +# location ~ /\. { +# deny all; +# access_log off; +# log_not_found off; +# } +# +# # 禁止敏感文件访问 +# location ~* \.(env|log|sql|bak|swp|tmp|zip|tar|gz)$ { +# deny all; +# access_log off; +# log_not_found off; +# } +# }