Compare commits

...

6 Commits

Author SHA1 Message Date
CI Bot 6cbd08f666 fix(code-quality): 第一批代码质量修复 - 零风险项
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m28s
- 修复 ruff 配置:移除已废弃规则 W503/E704(ruff 0.14+ 不兼容)
- 修复 F541 (13处):f-string 无占位符改为普通字符串
- 修复 B017 (1处):pytest.raises(Exception) 改为 ValidationError
- 修复 vulture 死代码:trim_engine 中 if False 的三元表达式
- 修复 mypy var-annotated:SUNSET_VERSIONS 加类型标注
2026-07-15 00:51:36 +08:00
xiaoxia 62ab361940 feat(ci): deploy-production接入健康检查+自动回滚
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m27s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m57s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m6s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 12m19s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
2026-07-15 00:31:08 +08:00
xiaoxia ed3ab34028 feat(ci): 升级部署通知脚本,卡片格式与CI通知对齐 2026-07-15 00:31:08 +08:00
xiaoxia df016b18fb feat(ci): 新增Production健康检查+自动回滚脚本 2026-07-15 00:31:08 +08:00
CI Bot c6aac862b1 fix(ci): 修复PR #332合并后CI失败 - F811重复import + url_security单测魔数校验
CI/CD Pipeline / Unit Tests (push) Successful in 1m52s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m22s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m50s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m55s
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
- generation.py: 删除重复的shared.url_security import(保留video_processing向后兼容层)
- test_url_security.py: mock数据添加ID3魔数头,适配新增的文件魔数校验
2026-07-15 00:20:36 +08:00
CI Bot 6232300fb1 fix(lint): 修复generation.py isort import排序问题
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47s
CI/CD Pipeline / Unit Tests (push) Failing after 1m41s
CI/CD Pipeline / Integration Tests (push) Successful in 1m20s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m55s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-15 00:14:18 +08:00
13 changed files with 653 additions and 56 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
"""处理已下线的 API 版本"""
SUNSET_VERSIONS = [] # 已下线的版本列表
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
async def dispatch(self, request: Request, call_next):
version = self._extract_version(request.url.path)
+2 -2
View File
@@ -186,8 +186,8 @@ class TrimEngine:
FFmpeg filter 字符串,如 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
"""
if trim.is_noop:
# 不裁剪,直接直通
return f"{input_label}copy{output_label}" if False else f"{input_label}setpts=PTS-STARTPTS{output_label}"
# 不裁剪,直接直通(仅重置时间戳)
return f"{input_label}setpts=PTS-STARTPTS{output_label}"
parts: list[str] = []
@@ -460,8 +460,6 @@ def _verify_url_accessible(
import urllib.request
from urllib.parse import urljoin
from shared.url_security import UrlSecurityError, validate_url_safety
from video_processing.url_security import UrlSecurityError, validate_url_safety
# P0-1 SSRF 防护:请求前先校验 URL 安全性
+1 -2
View File
@@ -80,7 +80,6 @@ select = [
# 与原 setup.cfg flake8 配置对齐,确保不新增阻断
ignore = [
"E203",
"W503",
"E501", # line-too-longblack管)
"E302",
"E402", # module-import-not-at-top(循环导入多)
@@ -97,6 +96,6 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"]
"tests/*" = ["E402", "F401", "F841"]
"packages/ports/*" = ["E301", "E704"]
"packages/ports/*" = ["E301"]
"apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"]
+1 -1
View File
@@ -123,7 +123,7 @@ def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
return [REPO_ROOT / f for f in files]
except subprocess.CalledProcessError as e:
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
print(f" 降级为检查所有迁移文件")
print(" 降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
+1 -1
View File
@@ -125,7 +125,7 @@ def main() -> int:
if mode != "start":
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{duration}"}})
else:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**状态**\n进行中"}})
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": "**状态**\n进行中"}})
if runner_name:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Runner**\n{runner_name}"}})
+483
View File
@@ -0,0 +1,483 @@
#!/bin/bash
# ===========================================
# CI Production 健康检查 + 自动回滚脚本(SSH 部署模式)
# ===========================================
#
# 在 CI Runner 上执行,通过公网 URL 检查 Production 部署健康状态。
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
#
# 用法:
# ./ci_production_healthcheck.sh
#
# 环境变量:
# PROD_API_URL - Production API 公网地址 (默认 https://api.xiaoxiajianji.com)
# PROD_WEB_URL - Production Web 公网地址 (默认 https://saas.xiaoxiajianji.com)
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 180)
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
#
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
# PRODUCTION_SSH_USER - SSH 用户名 (默认 root)
# PRODUCTION_SSH_PORT - SSH 端口 (默认 22222)
# PRODUCTION_SSH_KEY - SSH 私钥内容
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
#
# GITHUB_SHA - 当前 commit SHA
# GITHUB_REF_NAME - tag 名 (如 v0.1.100)
# GITHUB_RUN_ID - CI Run ID
# GITHUB_REPOSITORY - 仓库名
# GITHUB_ACTOR - 提交者
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# 配置
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-180}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# ===========================================
# SSH 工具函数
# ===========================================
SSH_KEY_PATH=""
setup_ssh() {
# 查找或创建 SSH 密钥
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
SSH_KEY_PATH="$HOME/.ssh/prod_deploy_key"
mkdir -p "$HOME/.ssh"
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH"
chmod 600 "$SSH_KEY_PATH"
else
log_error "没有可用的 SSH 密钥"
return 1
fi
ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}"
}
run_ssh() {
local cmd="$1"
ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
"${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd"
}
# ===========================================
# 1. 记录部署前各服务的镜像版本(用于回滚)
# ===========================================
ROLLBACK_API_TAG=""
ROLLBACK_WORKER_TAG=""
ROLLBACK_WEB_TAG=""
save_rollback_target() {
log_step "记录当前生产环境各服务镜像版本(回滚目标)..."
# 通过 SSH 获取当前运行的容器镜像
local api_image worker_image web_image
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-production 2>/dev/null || echo ''")
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-production 2>/dev/null || echo ''")
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-production 2>/dev/null || echo ''")
# 提取 tag(镜像名是 xiaoxia-saas-api:v0.1.100 格式)
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
log_info " API: ${ROLLBACK_API_TAG:-未知}"
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
# 验证三个服务版本是否一致
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
else
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
export ROLLBACK_TAG_MIXED="true"
fi
else
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
fi
}
# ===========================================
# 2. 健康检查(公网视角)
# ===========================================
health_check() {
local timeout="$HEALTH_CHECK_TIMEOUT"
local start_time
start_time=$(date +%s)
log_step "公网健康检查(超时 ${timeout}s..."
log_info " API: ${PROD_API_URL}/health"
log_info " Web: ${PROD_WEB_URL}/"
local api_ok=false
local web_ok=false
local api_docs_ok=false
local login_api_ok=false
while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do
# 检查 API health
if [ "$api_ok" = false ] && curl -sf --max-time 10 "${PROD_API_URL}/health" >/dev/null 2>&1; then
log_info "✅ API 健康检查通过"
api_ok=true
fi
# 检查 Web 首页
if [ "$web_ok" = false ] && curl -sf --max-time 10 "$PROD_WEB_URL/" >/dev/null 2>&1; then
log_info "✅ Web 前端检查通过"
web_ok=true
fi
# 检查 API docs
if [ "$api_docs_ok" = false ]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${PROD_API_URL}/docs" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
log_info "✅ API Docs 检查通过"
api_docs_ok=true
fi
fi
# 检查登录 API
if [ "$login_api_ok" = false ]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
"${PROD_API_URL}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)"
login_api_ok=true
fi
fi
# 都通过了就退出
if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then
log_info "🎉 所有健康检查通过!"
return 0
fi
sleep 5
done
# 超时了
log_error "❌ 健康检查超时 (${timeout}s)"
[ "$api_ok" = false ] && log_error " - API health 未通过"
[ "$web_ok" = false ] && log_error " - Web 前端未通过"
[ "$api_docs_ok" = false ] && log_error " - API Docs 未通过"
[ "$login_api_ok" = false ] && log_error " - 登录 API 未通过"
return 1
}
# ===========================================
# 3. 执行回滚(SSH 重新部署旧版本)
# ===========================================
do_rollback() {
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
local rollback_tag="${ROLLBACK_TAG:-}"
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
log_error "没有可回滚的版本记录,无法自动回滚"
return 1
fi
# 如果版本不一致,用 API 的版本作为回滚目标
if [ -z "$rollback_tag" ]; then
rollback_tag="$ROLLBACK_API_TAG"
fi
if [ -z "$rollback_tag" ]; then
log_error "无法确定回滚版本"
return 1
fi
log_info "回滚目标版本: $rollback_tag"
# 通过 SSH 在生产服务器上执行回滚部署
# 复用 Registry 方式部署脚本的逻辑,用旧版本 tag 重新部署
local rollback_script=$(cat << 'ROLLBACK_EOF'
#!/bin/sh
set -eu
IMAGE_TAG="$1"
REGISTRY_TOKEN="$2"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
echo "=== Rollback to $IMAGE_TAG ==="
# 登录 Registry
if [ -n "$REGISTRY_TOKEN" ]; then
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
fi
# Pull 旧版本镜像
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
echo "Rollback images pulled."
# 停止当前容器
docker rm -f xiaoxia-api-production 2>/dev/null || true
docker rm -f xiaoxia-worker-production 2>/dev/null || true
docker rm -f xiaoxia-web-production 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# 启动 API(回滚不跑 migration,因为新版本可能加了字段,回滚后代码是旧的但数据还在)
echo "Starting API (rollback)..."
docker run -d \
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
# 启动 Worker
echo "Starting Worker (rollback)..."
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
# 启动 Web
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
fi
echo "Starting Web (rollback)..."
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
# 等待 API 健康
echo "Waiting for API (rollback)..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
echo "API healthy (rollback)."
break
fi
i=$((i + 1))
sleep 3
done
# 等待 Web 健康
echo "Waiting for Web (rollback)..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
echo "Web healthy (rollback)."
break
fi
i=$((i + 1))
sleep 2
done
echo "=== Rollback complete: $IMAGE_TAG ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
ROLLBACK_EOF
)
# 将脚本 base64 编码后通过 SSH 执行
local script_b64
script_b64=$(echo "$rollback_script" | base64 -w 0)
log_info "在生产服务器上执行回滚脚本..."
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
log_info "✅ 回滚命令执行完成"
return 0
else
log_error "❌ 回滚命令执行失败"
return 1
fi
}
# ===========================================
# 4. 发送通知
# ===========================================
send_notification() {
local status="$1" # success / failure / rollback
local detail="$2"
if [ "${SKIP_NOTIFY:-false}" = "true" ]; then
log_info "跳过通知(SKIP_NOTIFY=true"
return 0
fi
local webhook="${CI_NOTIFY_WEBHOOK:-}"
if [ -z "$webhook" ]; then
log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
return 0
fi
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
python3 "$SCRIPT_DIR/deploy_notify.py" \
--status "$status" \
--detail "$detail" \
--webhook "$webhook" \
--env production \
2>/dev/null || log_warn "通知发送失败(非致命)"
else
log_warn "找不到 deploy_notify.py,跳过通知"
fi
# 标记:通知已由健康检查脚本发出,避免 CI 兜底通知重复发送
echo "$status" > /tmp/prod_deploy_notification_sent
}
# ===========================================
# 主流程
# ===========================================
main() {
echo ""
echo "=========================================="
echo " CI Production 健康检查 + 自动回滚"
echo "=========================================="
echo ""
local deploy_status="success"
local deploy_detail=""
# 1. 设置 SSH
if ! setup_ssh; then
log_error "SSH 配置失败,无法执行回滚"
fi
# 2. 记录部署前状态(回滚目标)
save_rollback_target || true
# 3. 健康检查(公网视角)
if ! health_check; then
log_error "健康检查失败"
deploy_status="failure"
deploy_detail="公网健康检查超时,部署后服务未正常响应"
# 自动回滚
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
log_warn "开始自动回滚..."
if do_rollback; then
deploy_status="rollback"
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
# 回滚后再检查一下公网状态
log_info "回滚完成,重新检查公网健康状态..."
if health_check; then
log_info "✅ 回滚后服务已恢复"
deploy_detail="${deploy_detail},回滚后服务已恢复"
else
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
fi
else
deploy_detail="健康检查失败且回滚失败,请手动排查"
fi
fi
send_notification "$deploy_status" "$deploy_detail"
# 失败时退出非零,让 CI Job 标记为失败
exit 1
fi
# 4. 成功
log_info ""
log_info "=================================="
log_info " ✅ Production 部署成功!"
log_info "=================================="
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_REF_NAME:-未知版本})"
send_notification "success" "$deploy_detail"
}
main "$@"
+125 -29
View File
@@ -1,13 +1,17 @@
#!/usr/bin/env python3
"""
Staging 部署通知脚本
部署通知脚本(支持 Staging / Production
与 CI 通知(ci_notify_success.py / ci_notify_failure.py)卡片格式对齐。
发送部署结果通知到飞书 webhook(卡片格式)。
支持三种状态:success / failure / rollback
支持两种环境:staging / production
用法:
python3 deploy_notify.py --status success --detail "部署成功" --webhook <url>
python3 deploy_notify.py --status rollback --detail "健康检查失败,已回滚"
python3 deploy_notify.py --status success --detail "部署成功" --env staging
python3 deploy_notify.py --status rollback --detail "健康检查失败,已回滚" --env production
python3 deploy_notify.py --status failure --detail "部署过程出错" --env production --failed-step "构建镜像"
"""
import argparse
@@ -19,73 +23,139 @@ import urllib.request
STATUS_CONFIG = {
"success": {
"emoji": "",
"title": "Staging 部署成功",
"title_suffix": "部署成功",
"color": "green",
"button_text": "查看构建详情",
"button_type": "primary",
},
"failure": {
"emoji": "",
"title": "Staging 部署失败",
"title_suffix": "部署失败",
"color": "red",
"button_text": "查看失败日志",
"button_type": "danger",
},
"rollback": {
"emoji": "↩️",
"title": "Staging 部署已回滚",
"title_suffix": "部署已回滚",
"color": "yellow",
"button_text": "查看构建详情",
"button_type": "primary",
},
}
ENV_CONFIG = {
"staging": {
"label": "Staging",
"url_web": "https://staging.xiaoxiajianji.com",
"url_api": "https://staging-api.xiaoxiajianji.com",
},
"production": {
"label": "Production",
"url_web": "https://saas.xiaoxiajianji.com",
"url_api": "https://api.xiaoxiajianji.com",
},
}
def build_card(status: str, detail: str) -> dict:
"""构建飞书卡片消息。"""
def build_card(
status: str,
detail: str,
env: str = "staging",
duration: str = "",
failed_step: str = "",
pr_url: str = "",
) -> dict:
"""构建飞书卡片消息(与 ci_notify_*.py 风格一致)。"""
cfg = STATUS_CONFIG.get(status, STATUS_CONFIG["failure"])
env_cfg = ENV_CONFIG.get(env, ENV_CONFIG["staging"])
title = f"{cfg['emoji']} {env_cfg['label']} {cfg['title_suffix']}"
commit = os.environ.get("GITHUB_SHA", "unknown")[:8]
branch = os.environ.get("GITHUB_REF_NAME", "unknown")
ref = os.environ.get("GITHUB_REF_NAME", "unknown")
actor = os.environ.get("GITHUB_ACTOR", "system")
run_id = os.environ.get("GITHUB_RUN_ID", "-")
repo = os.environ.get("GITHUB_REPOSITORY", "xiaoxia/xiaoxia-saas")
# 版本信息:tag 部署显示 tag,分支部署显示分支
if ref.startswith("v"):
version_info = f"版本 {ref}"
else:
version_info = ref
# 构建内容行(与 ci_notify_*.py 风格一致:**标签**: 值)
lines = []
# 详情行(部署特有)
if detail:
lines.append(f"**详情**: {detail}")
lines.append(f"**环境**: {env_cfg['label']}")
lines.append(f"**版本**: {version_info}")
# 失败阶段(失败/回滚时显示)
if failed_step and status in ("failure", "rollback"):
lines.append(f"**失败阶段**: {failed_step}")
# 耗时(可选)
if duration:
lines.append(f"**耗时**: {duration}")
lines.append(f"**提交**: {commit}")
lines.append(f"**提交者**: {actor}")
lines.append(f"**Run ID**: {run_id}")
elements = [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": (
f"**状态**: {cfg['emoji']} {cfg['title']}\n"
f"**详情**: {detail}\n"
f"**分支**: {branch}\n"
f"**提交**: {commit}\n"
f"**提交者**: {actor}\n"
),
"content": "\n".join(lines),
},
},
]
# 如果有 run_id,加一个查看详情按钮
# 查看详情按钮
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
if run_id and run_id != "-":
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
elements.append(
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看构建详情"},
"text": {"tag": "plain_text", "content": cfg["button_text"]},
"url": run_url,
"type": "primary",
"type": cfg["button_type"],
}
],
}
)
# 加 Staging 访问链接
# PR 链接(可选)
if pr_url:
elements.append(
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看 PR"},
"url": pr_url,
"type": "default",
}
],
}
)
# 访问地址(部署特有)
elements.append(
{
"tag": "note",
"elements": [
{
"tag": "plain_text",
"content": "Staging: https://staging.xiaoxiajianji.com",
"content": f"Web: {env_cfg['url_web']} | API: {env_cfg['url_api']}",
}
],
}
@@ -97,7 +167,7 @@ def build_card(status: str, detail: str) -> dict:
"header": {
"title": {
"tag": "plain_text",
"content": f"{cfg['emoji']} {cfg['title']}",
"content": title,
},
"status": cfg["color"],
},
@@ -106,9 +176,17 @@ def build_card(status: str, detail: str) -> dict:
}
def send_notification(webhook: str, status: str, detail: str) -> bool:
def send_notification(
webhook: str,
status: str,
detail: str,
env: str = "staging",
duration: str = "",
failed_step: str = "",
pr_url: str = "",
) -> bool:
"""发送通知到 webhook。"""
payload = build_card(status, detail)
payload = build_card(status, detail, env, duration, failed_step, pr_url)
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
@@ -121,7 +199,7 @@ def send_notification(webhook: str, status: str, detail: str) -> bool:
try:
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
print(f"通知已发送: {status}")
print(f"通知已发送: {env} {status}")
return True
except Exception as e:
print(f"通知发送失败: {e}", file=sys.stderr)
@@ -129,7 +207,7 @@ def send_notification(webhook: str, status: str, detail: str) -> bool:
def main():
parser = argparse.ArgumentParser(description="Staging 部署通知")
parser = argparse.ArgumentParser(description="部署通知脚本")
parser.add_argument(
"--status",
required=True,
@@ -137,6 +215,15 @@ def main():
help="部署状态",
)
parser.add_argument("--detail", default="", help="详情描述")
parser.add_argument(
"--env",
default="staging",
choices=["staging", "production"],
help="部署环境 (默认 staging)",
)
parser.add_argument("--duration", default="", help="部署耗时")
parser.add_argument("--failed-step", default="", help="失败阶段")
parser.add_argument("--pr-url", default="", help="PR 链接")
parser.add_argument(
"--webhook",
default=os.environ.get("CI_NOTIFY_WEBHOOK", ""),
@@ -146,10 +233,19 @@ def main():
args = parser.parse_args()
if not args.webhook:
print("未配置 webhook URL,跳过通知")
print("未配置 CI_NOTIFY_WEBHOOK,跳过通知")
print("如需启用,请在仓库 Settings -> Secrets and variables -> Actions 中添加 CI_NOTIFY_WEBHOOK")
return 0
send_notification(args.webhook, args.status, args.detail)
send_notification(
webhook=args.webhook,
status=args.status,
detail=args.detail,
env=args.env,
duration=args.duration,
failed_step=args.failed_step,
pr_url=args.pr_url,
)
return 0
+9 -9
View File
@@ -378,17 +378,17 @@ def init_phase6_tasks():
conn.commit()
conn.close()
print(f"\n[SUCCESS] Phase 6 任务初始化完成!")
print("\n[SUCCESS] Phase 6 任务初始化完成!")
print(f"📊 总计 {len(PHASE6_TASKS)} 个任务")
print(f"✅ 成功创建 {created_count} 个任务")
print(f"\n任务分布:")
print(f" Week 1-2: 基础搭建 - 7 个任务")
print(f" Week 3: 认证页面 - 5 个任务")
print(f" Week 4: 工作空间管理 - 6 个任务")
print(f" Week 5: 订阅管理 - 5 个任务")
print(f" Week 6: Admin 后台 - 5 个任务")
print(f" Week 7: 个人中心 - 4 个任务")
print(f" Week 8: 测试和优化 - 8 个任务")
print("\n任务分布:")
print(" Week 1-2: 基础搭建 - 7 个任务")
print(" Week 3: 认证页面 - 5 个任务")
print(" Week 4: 工作空间管理 - 6 个任务")
print(" Week 5: 订阅管理 - 5 个任务")
print(" Week 6: Admin 后台 - 5 个任务")
print(" Week 7: 个人中心 - 4 个任务")
print(" Week 8: 测试和优化 - 8 个任务")
print(f"\n预计总工时:{sum(t['estimated_hours'] for t in PHASE6_TASKS)} 小时")
+2 -2
View File
@@ -294,8 +294,8 @@ def main():
print("\n" + "=" * 60)
print("[OK] 数据初始化完成!")
print("=" * 60)
print(f"\n访问推进器: http://47.98.113.167:8088/projects")
print(f"访问 API 文档: http://47.98.113.167:8089/docs\n")
print("\n访问推进器: http://47.98.113.167:8088/projects")
print("访问 API 文档: http://47.98.113.167:8089/docs\n")
if __name__ == "__main__":
@@ -173,7 +173,9 @@ class TestConfigSchemas:
cfg = BGMConfig(volume=0.5)
assert cfg.volume == 0.5
with pytest.raises(Exception):
from pydantic import ValidationError
with pytest.raises(ValidationError):
BGMConfig(volume=1.5) # > 1.0 应该校验失败
def test_edit_plan_config_schema_full(self):
+3 -3
View File
@@ -233,7 +233,7 @@ class TestSafeDownload(unittest.TestCase):
with unittest.mock.patch("urllib.request.build_opener") as mock_opener:
mock_resp = unittest.mock.MagicMock()
mock_resp.headers = {"Content-Type": "audio/mpeg"}
mock_resp.read.side_effect = [b"audio_data", b""]
mock_resp.read.side_effect = [b"ID3audio_data", b""]
mock_resp.geturl.return_value = "https://example.com/test.mp3"
mock_opener.return_value.open.return_value = mock_resp
size = safe_download_file(
@@ -242,7 +242,7 @@ class TestSafeDownload(unittest.TestCase):
purpose="test",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
)
self.assertEqual(size, 10)
self.assertEqual(size, 13)
self.assertTrue(os.path.exists(dest))
def test_safe_download_file_stream_size_limit(self):
@@ -274,7 +274,7 @@ class TestSafeDownload(unittest.TestCase):
def test_safe_download_bytes_returns_content(self):
"""safe_download_bytes 应该返回文件内容."""
test_data = b"hello world test audio"
test_data = b"ID3hello world test audio"
with unittest.mock.patch("urllib.request.build_opener") as mock_opener:
mock_resp = unittest.mock.MagicMock()
mock_resp.headers = {"Content-Type": "audio/mpeg"}