diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..590bf3b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: python-learning CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: python-learning-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: 后端测试 + 前端构建 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: 安装 Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: 安装后端依赖 + run: pip install -r backend/requirements.txt + + - name: 后端测试(SQLite,无需外部服务) + run: python -m pytest backend/tests -q + + - name: 冒烟验证免登录契约(无 token 应拿到默认用户) + run: | + python - <<'PY' + import sys, tempfile + sys.path.insert(0, "backend") + from fastapi.testclient import TestClient + from app.main import create_app + + db = tempfile.mktemp(suffix=".db") + client = TestClient(create_app(database_url=f"sqlite:///{db}")) + + me = client.get("/api/auth/me") + assert me.status_code == 200, f"/api/auth/me 应免登录放行,实际 {me.status_code}: {me.text}" + print("免登录身份:", me.json()) + + for path in ["/api/health", "/api/summary", "/api/chapters"]: + r = client.get(path) + assert r.status_code == 200, f"{path} -> {r.status_code}: {r.text[:200]}" + print(f" {path} OK") + + w = client.patch("/api/progress", json={"chapter_no": 1, "layer": "theory", "done": True}) + assert w.status_code == 200, f"免登录写入应成功,实际 {w.status_code}: {w.text}" + print("免登录写入 OK:", w.json()) + print("✅ 免登录契约验证通过") + PY + + - name: 安装 Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: 安装前端依赖 + run: npm ci --no-audit --no-fund + + - name: TypeScript 类型检查 + 构建 + run: npm run build + # 注:仓库结构检查(tools/check_learning_repo.py)已在 quality.yml 中单独运行,此处不重复 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..278dd41 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,102 @@ +name: python-learning Deploy + +# 部署通道:阿里云 Cloud Assistant(aliyun ecs RunCommand),不用 SSH。 +# 原因同 java-study:GitHub Actions runner 属境外出口,直连 47.93.15.207:22 会被 +# 阿里云上游静默丢包(安全组规则在该层之下,加了也无效)。Cloud Assistant 走 HTTPS API。 + +on: + push: + branches: [main] + paths-ignore: + - '**/*.md' + - 'docs/**' + workflow_dispatch: + +concurrency: + group: python-learning-deploy + cancel-in-progress: false + +jobs: + deploy: + name: 部署到 ECS + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - name: 记录版本 + id: before + run: | + echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "message=$(git log -1 --pretty='%s')" >> "$GITHUB_OUTPUT" + + - name: 通过 Cloud Assistant 下发部署脚本 + env: + ALICLOUD_ACCESS_KEY_ID: ${{ secrets.ALIYUN_AK }} + ALICLOUD_ACCESS_KEY_SECRET: ${{ secrets.ALIYUN_SK }} + REGION: cn-beijing + INSTANCE_ID: i-2zecv4kgasiw24p97v0t + run: | + set -eo pipefail + curl -fsSL -o aliyun-cli.tgz https://aliyuncli.alicdn.com/aliyun-cli-linux-latest-amd64.tgz + tar -xzf aliyun-cli.tgz + install -m 0755 aliyun /usr/local/bin/aliyun + + aliyun configure set --mode AK --profile ecs-deploy \ + --region "$REGION" \ + --access-key-id "$ALICLOUD_ACCESS_KEY_ID" \ + --access-key-secret "$ALICLOUD_ACCESS_KEY_SECRET" >/dev/null + + IID=$(aliyun ecs RunCommand --profile ecs-deploy --region "$REGION" \ + --Type RunShellScript --Timeout 1800 \ + --InstanceId.1 "$INSTANCE_ID" \ + --ContentEncoding Base64 \ + --CommandContent "$(base64 -w0 < deploy/deploy-python.sh)" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['InvokeId'])") + echo "InvokeId=$IID" + echo "::add-mask::$IID" + + while :; do + STATUS=$(aliyun ecs DescribeInvocationResults --profile ecs-deploy \ + --region "$REGION" --InvokeId "$IID" | python3 deploy/ca_result.py status) + echo " 状态: $STATUS" + case "$STATUS" in Running|Pending) sleep 15 ;; *) break ;; esac + done + + # 用仓库内脚本解析,避免内联多行 Python 顶断 YAML 缩进 + aliyun ecs DescribeInvocationResults --profile ecs-deploy \ + --region "$REGION" --InvokeId "$IID" | python3 deploy/ca_result.py output \ + | tee /tmp/ca-output.log + + grep -q "PYTHON-LEARNING DEPLOY DONE" /tmp/ca-output.log || { + echo "::error::部署未完成"; exit 1; } + + - name: 部署结果 + if: always() + run: | + { + echo "### 部署完成" + echo "" + echo "- commit: \`${{ steps.before.outputs.commit }}\` — ${{ steps.before.outputs.message }}" + echo "- 站点: http://47.93.15.207:8084/" + echo "" + echo '```' + tail -30 /tmp/ca-output.log 2>/dev/null || echo '(无输出)' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: 外网复验(无 token) + run: | + set -eo pipefail + sleep 5 + echo -n "首页 : " + curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://47.93.15.207:8084/ + echo -n "auth/me : " + curl -sS --max-time 15 http://47.93.15.207:8084/api/auth/me + echo "" + echo -n "summary : " + curl -sS --max-time 15 -o /dev/null -w '%{http_code}\n' http://47.93.15.207:8084/api/summary + echo -n "health : " + curl -sS --max-time 15 http://47.93.15.207:8084/api/health + echo "" \ No newline at end of file diff --git a/deploy/ca_result.py b/deploy/ca_result.py new file mode 100644 index 0000000..f61df20 --- /dev/null +++ b/deploy/ca_result.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""解析 `aliyun ecs DescribeInvocationResults` 的 JSON 输出。 + +用法: + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py status + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py output + aliyun ecs DescribeInvocationResults ... | python3 ca_result.py both + +单独放成文件而不是内联在 workflow 的 run: | 块里,是因为内联多行 Python +必须顶格写,会顶断 YAML 的块标量缩进。 +""" +from __future__ import annotations + +import base64 +import json +import sys + + +def first_result(payload: dict) -> dict: + invocation = payload["Invocation"]["InvocationResults"] + results = invocation["InvocationResult"] + if not results: + raise SystemExit("DescribeInvocationResults 返回空结果") + return results[0] + + +def main() -> int: + if len(sys.argv) < 2 or sys.argv[1] not in {"status", "output", "both"}: + print(__doc__, file=sys.stderr) + return 2 + + mode = sys.argv[1] + result = first_result(json.load(sys.stdin)) + + if mode in {"status", "both"}: + print(result.get("InvocationStatus", "")) + + if mode in {"output", "both"}: + raw = result.get("Output", "") or "" + sys.stdout.write(base64.b64decode(raw).decode(errors="replace")) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/deploy/deploy-python.sh b/deploy/deploy-python.sh new file mode 100644 index 0000000..7f0ff15 --- /dev/null +++ b/deploy/deploy-python.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# python-learning 免登录版部署脚本(在 ECS 上执行) +# +# 关键事实(探查所得): +# - 后端由 PM2 托管:python-learning-workbench-api +# - 必须用 /usr/bin/python3.11;系统默认 python3 是 3.6.8,装不了 FastAPI +# - pm2 不认 --app-dir(那是 uvicorn 的参数,不是 pm2 的),故用 ecosystem + PYTHONPATH +# - 持久化:python-workbench.db 与 node_modules 必须保留 +set -eo pipefail + +SITE_DIR='/www/wwwroot/python-learning-workbench' +PY=/usr/bin/python3.11 +PM2_NAME=python-learning-workbench-api +BACKEND_PORT=8004 + +echo "=== 0. 前置检查 ===" +[ -x "$PY" ] || { echo "❌ $PY 不存在"; exit 1; } +"$PY" --version + +echo "=== 1. 下载源码 ===" +TAR=/tmp/_python-learning.tar.gz +rm -f "$TAR" +http=$(curl -sSL -o "$TAR" -w '%{http_code}' --max-time 240 \ + "https://codeload.github.com/webxiongda/python-learning/tar.gz/refs/heads/main") +[ "$http" = "200" ] || { echo "❌ 下载失败 HTTP=$http"; exit 1; } +tar -tzf "$TAR" >/dev/null && echo "tar OK ($(du -h $TAR | cut -f1))" + +echo "=== 2. 备份并替换源码(保留 db 与 node_modules)===" +BAK="${SITE_DIR}-bak-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$BAK" +cd "$SITE_DIR" +for item in backend/app src index.html package.json package-lock.json tsconfig.app.json tsconfig.json tsconfig.node.json; do + [ -e "$item" ] && cp -a "$item" "$BAK/" 2>/dev/null || true +done +echo "已备份 -> $BAK" + +KEEP=/tmp/_py_keep +rm -rf "$KEEP"; mkdir -p "$KEEP" +[ -f python-workbench.db ] && mv python-workbench.db "$KEEP/" || true +[ -d node_modules ] && mv node_modules "$KEEP/" || true +rm -rf backend src dist +tar -xzf "$TAR" --strip-components=1 +rm -f "$TAR" +[ -f "$KEEP/python-workbench.db" ] && mv "$KEEP/python-workbench.db" ./ || true +[ -d "$KEEP/node_modules" ] && mv "$KEEP/node_modules" ./ || true +rm -rf "$KEEP" + +echo "免登录逻辑: $(grep -q AUTO_LOGIN_USERNAME backend/app/auth.py && echo YES || echo '❌ 缺失')" +echo "db 保留: $([ -f python-workbench.db ] && echo YES || echo '❌ 缺失')" + +echo "=== 3. 安装后端依赖 ===" +"$PY" -m pip install -q -r backend/requirements.txt 2>&1 | grep -v "WARNING: Running pip" | tail -3 +"$PY" -c "import fastapi, sqlalchemy, uvicorn; print('依赖 OK, fastapi', fastapi.__version__)" + +echo "=== 4. 构建前端 ===" +export PATH=/usr/local/bin:$PATH +[ -d node_modules ] || npm install --no-audit --no-fund 2>&1 | tail -3 +npm run build 2>&1 | tail -8 + +echo "=== 5. 重启 PM2 后端 ===" +cat > "$SITE_DIR/ecosystem.config.cjs" << 'EOF' +module.exports = { + apps: [ + { + name: 'python-learning-workbench-api', + script: '/usr/bin/python3.11', + args: '-m uvicorn app.main:app --host 127.0.0.1 --port 8004', + interpreter: 'none', + cwd: '/www/wwwroot/python-learning-workbench', + env: { + PYTHONPATH: '/www/wwwroot/python-learning-workbench/backend', + PYTHONUNBUFFERED: '1' + }, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '400M' + } + ] +}; +EOF + +pm2 delete "$PM2_NAME" 2>/dev/null || true +pm2 start "$SITE_DIR/ecosystem.config.cjs" 2>&1 | tail -5 +pm2 save --force 2>&1 | tail -1 + +echo "=== 6. 等待就绪并做免登录验证(无 Authorization 头)===" +for i in $(seq 1 15); do + sleep 4 + code=$(curl -s -m 4 -o /dev/null -w "%{http_code}" "http://127.0.0.1:${BACKEND_PORT}/api/health" 2>/dev/null || echo 000) + echo " 探测 ${i}: $code" + [ "$code" = "200" ] && break + [ "$i" = "15" ] && { echo "❌ 未就绪"; pm2 logs "$PM2_NAME" --lines 30 --nostream; exit 1; } +done + +echo -n "health : "; curl -s -m 8 "http://127.0.0.1:${BACKEND_PORT}/api/health"; echo "" +echo -n "auth/me : "; curl -s -m 8 "http://127.0.0.1:${BACKEND_PORT}/api/auth/me"; echo "" +for p in summary chapters; do + printf " %-12s %s\n" "$p:" "$(curl -s -m 8 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${BACKEND_PORT}/api/$p")" +done + +echo "" +echo "如需回滚:cp -a '$BAK/backend' '$SITE_DIR/backend' && pm2 restart $PM2_NAME" +echo "=== PYTHON-LEARNING DEPLOY DONE ===" \ No newline at end of file