Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 中单独运行,此处不重复
102 changes: 102 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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 ""
46 changes: 46 additions & 0 deletions deploy/ca_result.py
Original file line number Diff line number Diff line change
@@ -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())
104 changes: 104 additions & 0 deletions deploy/deploy-python.sh
Original file line number Diff line number Diff line change
@@ -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 ==="
Loading