feat(config): Phase 4 — 修正模板 + 渲染脚本 + CI 部署改造 #1630
@@ -1169,6 +1169,31 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=start JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
|
||||
- name: Render .env from template
|
||||
shell: sh
|
||||
env:
|
||||
STAGING_DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
|
||||
STAGING_REDIS_URL: ${{ secrets.STAGING_REDIS_URL }}
|
||||
STAGING_CELERY_BROKER_URL: ${{ secrets.STAGING_CELERY_BROKER_URL }}
|
||||
STAGING_CELERY_RESULT_BACKEND: ${{ secrets.STAGING_CELERY_RESULT_BACKEND }}
|
||||
STAGING_JWT_SECRET_KEY: ${{ secrets.STAGING_JWT_SECRET_KEY }}
|
||||
STAGING_MINIO_ENDPOINT: ${{ secrets.STAGING_MINIO_ENDPOINT }}
|
||||
STAGING_MINIO_ACCESS_KEY: ${{ secrets.STAGING_MINIO_ACCESS_KEY }}
|
||||
STAGING_MINIO_SECRET_KEY: ${{ secrets.STAGING_MINIO_SECRET_KEY }}
|
||||
STAGING_MINIO_BUCKET: ${{ secrets.STAGING_MINIO_BUCKET }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
COSYVOICE_API_KEY: ${{ secrets.COSYVOICE_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
MEDIAKIT_API_KEY: ${{ secrets.MEDIAKIT_API_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "Rendering .env from template + secrets..."
|
||||
bash scripts/render_env.sh staging
|
||||
echo "✅ .env rendered (file contains secrets, not printed to log)"
|
||||
# 验证文件存在且非空
|
||||
test -s .env.rendered
|
||||
echo "✅ .env.rendered validated ($(wc -l < .env.rendered) lines)"
|
||||
- name: Docker login to Registry
|
||||
shell: sh
|
||||
env:
|
||||
@@ -1241,9 +1266,22 @@ jobs:
|
||||
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "echo SSH_CONNECTION_OK && hostname"
|
||||
echo "SSH connection verified"
|
||||
|
||||
# 上传渲染后的 .env 到服务器(替代服务器上旧的 .env)
|
||||
echo "Uploading rendered .env to staging server..."
|
||||
# 备份旧 .env
|
||||
ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" \
|
||||
"cp -f /var/lib/xiaoxia-saas-staging/.env /var/lib/xiaoxia-saas-staging/.env.bak.\$(date +%Y%m%d%H%M%S) 2>/dev/null || true"
|
||||
# 上传新 .env
|
||||
scp -P "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no .env.rendered \
|
||||
"${staging_user}@${staging_host}:/var/lib/xiaoxia-saas-staging/.env"
|
||||
echo "✅ .env uploaded to staging server"
|
||||
|
||||
# 通过环境变量传递凭证,避免命令行引号转义问题
|
||||
cat scripts/ci_staging_deploy.sh | ssh -p "$staging_port" -i "$key_path" -o StrictHostKeyChecking=no "${staging_user}@${staging_host}" "IMAGE_TAG=${GITHUB_SHA} ACR_USERNAME=${ACR_USERNAME} ACR_PASSWORD=${ACR_PASSWORD} sh"
|
||||
|
||||
# 清理 CI runner 上的渲染文件
|
||||
rm -f .env.rendered
|
||||
|
||||
- name: Staging health check + auto rollback
|
||||
if: success()
|
||||
shell: sh
|
||||
|
||||
@@ -27,6 +27,8 @@ ruff_cache/
|
||||
# 配置模板不受忽略规则限制
|
||||
!deploy/configs/.env.staging
|
||||
!deploy/configs/.env.production
|
||||
# 渲染后的 env 文件包含真实密钥,绝不能提交
|
||||
.env.rendered
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
|
||||
@@ -104,25 +104,23 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
|
||||
# 启用邮件投递
|
||||
ENABLE_EMAIL_DELIVERY=true
|
||||
# 邮件功能尚未上线,暂时关闭
|
||||
ENABLE_EMAIL_DELIVERY=false
|
||||
|
||||
# SMTP 服务器地址
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_HOST=
|
||||
|
||||
# SMTP 端口
|
||||
SMTP_PORT=587
|
||||
|
||||
# SMTP 用户名
|
||||
# ${SMTP_USER} — 替换为实际的 SMTP 用户名
|
||||
SMTP_USER=${SMTP_USER}
|
||||
# SMTP 用户名(邮件功能上线后配置)
|
||||
SMTP_USER=
|
||||
|
||||
# SMTP 密码 / 应用专用密码
|
||||
# ${SMTP_PASSWORD} — 替换为实际的 SMTP 密码
|
||||
SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
# SMTP 密码(邮件功能上线后配置)
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# 发件人邮箱
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
# 发件人邮箱(邮件功能上线后配置)
|
||||
SMTP_FROM_EMAIL=
|
||||
|
||||
# 发件人显示名称
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
@@ -157,7 +155,7 @@ OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
||||
# ==================== CORS 配置 ====================
|
||||
|
||||
# 允许跨域的前端域名列表,逗号分隔
|
||||
CORS_ORIGINS_RAW=https://xiaoxiajianji.com,https://www.xiaoxiajianji.com
|
||||
CORS_ORIGINS_RAW=https://xiaoxiajianji.com,https://api.xiaoxiajianji.com
|
||||
|
||||
|
||||
# ==================== 生成文件路径 ====================
|
||||
@@ -191,35 +189,26 @@ COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
|
||||
# 音色:v3 系列系统音色带 _v3 后缀
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_VOICE=longxiaoxia_v3
|
||||
|
||||
# 采样率
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
|
||||
# 输出格式
|
||||
COSYVOICE_FORMAT=mp3
|
||||
COSYVOICE_FORMAT=wav
|
||||
|
||||
# 音色克隆模型名(固定值)
|
||||
COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
|
||||
# DashScope 通用 API Key(与 CosyVoice 共用)
|
||||
DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
|
||||
|
||||
# ==================== 豆包大模型(火山引擎方舟)====================
|
||||
|
||||
# 豆包 API Key
|
||||
# ${DOUBAO_API_KEY} — 替换为实际的豆包 API Key
|
||||
DOUBAO_API_KEY=${DOUBAO_API_KEY}
|
||||
# ==================== MediaKit 视频理解(火山引擎)====================
|
||||
|
||||
# 模型名称
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
|
||||
# API 基础 URL
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
# 请求超时时间(秒)
|
||||
DOUBAO_TIMEOUT=30
|
||||
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
MEDIAKIT_API_KEY=${MEDIAKIT_API_KEY}
|
||||
MEDIAKIT_BASE_URL=https://mediakit.cn-beijing.volces.com/api/v1
|
||||
MEDIAKIT_TIMEOUT=60
|
||||
|
||||
|
||||
# ==================== 监控(可选)====================
|
||||
|
||||
+21
-32
@@ -14,8 +14,8 @@ APP_NAME=xiaoxia-saas
|
||||
# 环境标识
|
||||
APP_ENV=staging
|
||||
|
||||
# 关闭 Debug 模式
|
||||
DEBUG=false
|
||||
# Staging 开启 Debug 模式便于排查问题
|
||||
DEBUG=true
|
||||
|
||||
# 应用基础 URL(前端页面地址)
|
||||
APP_BASE_URL=https://staging.xiaoxiajianji.com
|
||||
@@ -96,7 +96,7 @@ JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
# Access Token 过期时间(分钟)
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# Refresh Token 过期时间(天)
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
@@ -104,8 +104,8 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
|
||||
# 启用邮件投递
|
||||
ENABLE_EMAIL_DELIVERY=true
|
||||
# 邮件功能尚未上线,暂时关闭
|
||||
ENABLE_EMAIL_DELIVERY=false
|
||||
|
||||
# SMTP 服务器地址
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
@@ -113,16 +113,14 @@ SMTP_HOST=smtp.gmail.com
|
||||
# SMTP 端口
|
||||
SMTP_PORT=587
|
||||
|
||||
# SMTP 用户名
|
||||
# ${SMTP_USER} — 替换为实际的 SMTP 用户名
|
||||
SMTP_USER=${SMTP_USER}
|
||||
# SMTP 用户名(邮件功能上线后配置)
|
||||
SMTP_USER=
|
||||
|
||||
# SMTP 密码 / 应用专用密码
|
||||
# ${SMTP_PASSWORD} — 替换为实际的 SMTP 密码
|
||||
SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
# SMTP 密码(邮件功能上线后配置)
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# 发件人邮箱
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
# 发件人邮箱(邮件功能上线后配置)
|
||||
SMTP_FROM_EMAIL=
|
||||
|
||||
# 发件人显示名称
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
@@ -170,7 +168,7 @@ MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
|
||||
# MinIO Bucket 名称
|
||||
MINIO_BUCKET=${MINIO_BUCKET}
|
||||
MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
|
||||
|
||||
# 是否使用 SSL 连接 MinIO
|
||||
MINIO_USE_SSL=false
|
||||
@@ -179,7 +177,7 @@ MINIO_USE_SSL=false
|
||||
# ==================== CORS 配置 ====================
|
||||
|
||||
# 允许跨域的前端域名列表,逗号分隔
|
||||
CORS_ORIGINS_RAW=https://staging.xiaoxiajianji.com,https://staging-web.xiaoxiajianji.com
|
||||
CORS_ORIGINS_RAW=https://staging.xiaoxiajianji.com,https://staging-api.xiaoxiajianji.com
|
||||
|
||||
|
||||
# ==================== 生成文件路径 ====================
|
||||
@@ -213,32 +211,23 @@ COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
|
||||
# 音色:v3 系列系统音色带 _v3 后缀
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_VOICE=longxiaoxia_v3
|
||||
|
||||
# 采样率
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
|
||||
# 输出格式
|
||||
COSYVOICE_FORMAT=mp3
|
||||
COSYVOICE_FORMAT=wav
|
||||
|
||||
# 音色克隆模型名(固定值)
|
||||
COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
|
||||
# DashScope 通用 API Key(与 CosyVoice 共用)
|
||||
DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
|
||||
|
||||
# ==================== 豆包大模型(火山引擎方舟)====================
|
||||
|
||||
# 豆包 API Key
|
||||
# ${DOUBAO_API_KEY} — 替换为实际的豆包 API Key
|
||||
DOUBAO_API_KEY=${DOUBAO_API_KEY}
|
||||
# ==================== MediaKit 视频理解(火山引擎)====================
|
||||
|
||||
# 模型名称
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
|
||||
# API 基础 URL
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
# 请求超时时间(秒)
|
||||
DOUBAO_TIMEOUT=30
|
||||
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
MEDIAKIT_API_KEY=${MEDIAKIT_API_KEY}
|
||||
MEDIAKIT_BASE_URL=https://mediakit.cn-beijing.volces.com/api/v1
|
||||
MEDIAKIT_TIMEOUT=60
|
||||
|
||||
@@ -55,7 +55,13 @@ if [ -z "$IMAGE_TAG" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
# .env 文件由 CI 从模板 + Secrets 渲染后通过 SCP 上传到服务器
|
||||
# 如果文件不存在,说明 CI 渲染步骤失败或未执行
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "ERROR: $ENV_FILE 不存在。CI 应先在 render_env 步骤渲染并上传此文件"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ .env file found: $ENV_FILE ($(wc -l < "$ENV_FILE") lines)"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# ===========================================================
|
||||
# render_env.sh — 从模板 + Secrets 渲染 .env 文件
|
||||
# ===========================================================
|
||||
# 用法: scripts/render_env.sh <staging|production>
|
||||
#
|
||||
# 输入: deploy/configs/.env.staging 或 .env.production 模板
|
||||
# 输出: .env.rendered(包含真实密钥,切勿提交或打印)
|
||||
#
|
||||
# 环境变量映射规则:
|
||||
# STAGING_xxx / PRODUCTION_xxx → xxx(去掉环境前缀)
|
||||
# 共用 secrets 直接使用(如 OSS_ACCESS_KEY_ID)
|
||||
# ===========================================================
|
||||
set -eu
|
||||
|
||||
TARGET_ENV="${1:-}"
|
||||
|
||||
if [ -z "$TARGET_ENV" ] || { [ "$TARGET_ENV" != "staging" ] && [ "$TARGET_ENV" != "production" ]; }; then
|
||||
echo "ERROR: 用法: $0 <staging|production>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMPLATE_FILE="deploy/configs/.env.${TARGET_ENV}"
|
||||
OUTPUT_FILE=".env.rendered"
|
||||
|
||||
if [ ! -f "$TEMPLATE_FILE" ]; then
|
||||
echo "ERROR: 模板文件不存在: $TEMPLATE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 构建环境变量映射(带环境前缀的 secrets → 模板变量名)
|
||||
ENV_PREFIX=$(echo "$TARGET_ENV" | tr '[:lower:]' '[:upper:]')
|
||||
|
||||
# 需要映射的带环境前缀变量
|
||||
MAPPED_VARS="DATABASE_URL REDIS_URL CELERY_BROKER_URL CELERY_RESULT_BACKEND JWT_SECRET_KEY"
|
||||
|
||||
# Staging 独有的 MinIO 变量
|
||||
if [ "$TARGET_ENV" = "staging" ]; then
|
||||
MAPPED_VARS="$MAPPED_VARS MINIO_ENDPOINT MINIO_ACCESS_KEY MINIO_SECRET_KEY"
|
||||
fi
|
||||
|
||||
# 将带前缀的 secrets 导出为无前缀的环境变量
|
||||
for var in $MAPPED_VARS; do
|
||||
prefixed_var="${ENV_PREFIX}_${var}"
|
||||
value="${!prefixed_var:-}"
|
||||
if [ -n "$value" ]; then
|
||||
export "$var=$value"
|
||||
fi
|
||||
done
|
||||
|
||||
# 特殊映射:CI secret 名称与模板占位符不一致的变量
|
||||
# STAGING_MINIO_BUCKET → MINIO_BUCKET_NAME
|
||||
if [ "$TARGET_ENV" = "staging" ]; then
|
||||
if [ -n "${STAGING_MINIO_BUCKET:-}" ]; then
|
||||
export "MINIO_BUCKET_NAME=$STAGING_MINIO_BUCKET"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
done
|
||||
|
||||
# 使用 Python 进行变量替换(Python 在 CI runner 中一定存在)
|
||||
python3 - "$TEMPLATE_FILE" "$OUTPUT_FILE" "$ENV_PREFIX" "$MAPPED_VARS" "$SHARED_SECRETS" <<'PYTHON_SCRIPT'
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
template_file = sys.argv[1]
|
||||
output_file = sys.argv[2]
|
||||
env_prefix = sys.argv[3]
|
||||
mapped_vars_str = sys.argv[4]
|
||||
shared_secrets_str = sys.argv[5]
|
||||
|
||||
# 收集所有可用的替换变量
|
||||
all_vars = set()
|
||||
for v in mapped_vars_str.split():
|
||||
all_vars.add(v)
|
||||
for v in shared_secrets_str.split():
|
||||
all_vars.add(v)
|
||||
|
||||
# 读取模板
|
||||
with open(template_file, 'r') as f:
|
||||
template = f.read()
|
||||
|
||||
# 找出模板中所有的 ${VAR} 占位符(仅检查非注释行)
|
||||
pattern = re.compile(r'\$\{(\w+)\}')
|
||||
placeholders = set()
|
||||
for line in template.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('#'):
|
||||
continue
|
||||
placeholders.update(pattern.findall(line))
|
||||
|
||||
# 检查必需变量是否已设置
|
||||
missing = []
|
||||
for var in placeholders:
|
||||
value = os.environ.get(var, '')
|
||||
if not value:
|
||||
missing.append(var)
|
||||
|
||||
if missing:
|
||||
print(f"ERROR: 以下变量未设置或为空: {', '.join(sorted(missing))}", file=sys.stderr)
|
||||
print(f"请确认对应的 {env_prefix}_xxx 或共用 secrets 已在 Gitea Secrets 中配置", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 执行替换
|
||||
def replace_var(match):
|
||||
var_name = match.group(1)
|
||||
return os.environ.get(var_name, match.group(0))
|
||||
|
||||
rendered = pattern.sub(replace_var, template)
|
||||
|
||||
# 写入输出文件
|
||||
with open(output_file, 'w') as f:
|
||||
f.write(rendered)
|
||||
|
||||
# 设置文件权限为仅 owner 可读写
|
||||
os.chmod(output_file, 0o600)
|
||||
|
||||
print(f"✅ .env 渲染完成: {template_file} → {output_file}")
|
||||
print(f" 替换了 {len(placeholders)} 个变量")
|
||||
PYTHON_SCRIPT
|
||||
|
||||
# 验证输出文件
|
||||
if [ ! -f "$OUTPUT_FILE" ]; then
|
||||
echo "ERROR: 渲染失败,输出文件不存在" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查输出文件中是否还有未替换的占位符(仅检查非注释行)
|
||||
if grep -vE '^\s*#' "$OUTPUT_FILE" | grep -qE '\$\{[A-Z_]+\}'; then
|
||||
echo "ERROR: 输出文件中仍有未替换的占位符:" >&2
|
||||
grep -nE '\$\{[A-Z_]+\}' "$OUTPUT_FILE" | grep -v '^\s*#' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 渲染文件校验通过,无残留占位符"
|
||||
echo "⚠️ $OUTPUT_FILE 包含敏感信息,请勿提交或打印到日志"
|
||||
Reference in New Issue
Block a user