diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 581c84f..a3a2849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/pip - key: pip-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('requirements-dev.txt') }} + key: pip-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('requirements-admin.txt', 'requirements-dev.txt', 'constraints.txt') }} restore-keys: | pip-${{ runner.os }}-py${{ matrix.python-version }}- pip-${{ runner.os }}- @@ -47,13 +47,13 @@ jobs: python -m pip install --upgrade pip # clean env 必须同时安装测试依赖 + admin 运行依赖,避免本地环境污染掩盖导入问题 if [ -f requirements-dev.txt ]; then - pip install -r requirements-dev.txt + pip install -c constraints.txt -r requirements-dev.txt else echo "::warning::requirements-dev.txt not found, installing test deps directly" - pip install pytest pytest-cov + pip install -c constraints.txt pytest pytest-cov fi if [ -f requirements-admin.txt ]; then - pip install -r requirements-admin.txt + pip install -c constraints.txt -r requirements-admin.txt fi - name: Verify Python and pytest versions diff --git a/Dockerfile b/Dockerfile index 429b813..022eb2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,13 +13,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app -COPY requirements-admin.txt /tmp/requirements-admin.txt +COPY constraints.txt requirements-admin.txt /tmp/ RUN python -m pip install --upgrade pip \ - && python -m pip install -r /tmp/requirements-admin.txt + && python -m pip install -c /tmp/constraints.txt -r /tmp/requirements-admin.txt COPY . /app +ARG GAOKAO_ADMIN_BIND=0.0.0.0 +ARG GAOKAO_ADMIN_PORT=8000 +ENV GAOKAO_ADMIN_BIND=${GAOKAO_ADMIN_BIND} \ + GAOKAO_ADMIN_PORT=${GAOKAO_ADMIN_PORT} + EXPOSE 8000 -CMD ["python", "-m", "admin.app", "--host", "0.0.0.0", "--port", "8000", "--log-format", "json"] \ No newline at end of file +CMD ["sh", "-c", "python -m admin.app --host ${GAOKAO_ADMIN_BIND} --port ${GAOKAO_ADMIN_PORT} --log-format json"] diff --git a/INSTALL.md b/INSTALL.md index 8b243d9..f3dfed4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -55,31 +55,32 @@ chmod +x ~/.local/bin/gaokao-checker ## 🐍 Python依赖安装 -### 安装必要包 +### 创建并激活项目虚拟环境 ```bash -# 基础依赖 -pip3 install --user --break-system-packages \ - weasyprint \ - jinja2 \ - markdown +cd /home/long/project/gaokao-volunteer-system +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +``` -# 可选依赖(用于开发和测试) -pip3 install --user --break-system-packages \ - pytest \ - black \ - flake8 +### 按锁定约束安装依赖 + +```bash +python -m pip install -c constraints.txt \ + -r requirements-admin.txt \ + -r requirements-dev.txt ``` ### 验证安装 ```bash # 检查Python版本 -python3 --version +python --version # 检查必要包 -python3 -c "import weasyprint; print('weasyprint OK')" -python3 -c "import jinja2; print('jinja2 OK')" +python -c "import weasyprint; print('weasyprint OK')" +python -c "import jinja2; print('jinja2 OK')" # 检查脚本 which gaokao-checker @@ -134,7 +135,7 @@ sudo apt-get install wkhtmltopdf cd /home/long/project/gaokao-volunteer-system # 运行自动化测试 -python3 tests/test_all.py +python -m pytest -q # 预期输出:所有测试通过 ``` @@ -143,13 +144,13 @@ python3 tests/test_all.py ```bash # 测试规范检查器 -python3 ~/.local/bin/gaokao-checker tests/cases/hunan-578.md +python ~/.local/bin/gaokao-checker tests/cases/hunan-578.md # 测试可视化报告 -python3 ~/.local/bin/gaokao-visual-report-v2.py +python ~/.local/bin/gaokao-visual-report-v2.py # 测试问卷 -python3 ~/.local/bin/gaokao-quick-3min.py +python ~/.local/bin/gaokao-quick-3min.py ``` --- @@ -171,20 +172,19 @@ source ~/.bashrc which gaokao-checker ``` -### Q2: `pip3 install` 失败 +### Q2: 依赖安装失败 -**原因**: 权限问题或系统包限制 +**原因**: 未激活项目 `.venv`、系统包限制,或未使用锁定约束 **解决**: ```bash -# 使用 --user 参数 -pip3 install --user weasyprint jinja2 markdown - -# 或使用虚拟环境 -python3 -m venv venv -source venv/bin/activate -pip3 install -r requirements.txt +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -c constraints.txt \ + -r requirements-admin.txt \ + -r requirements-dev.txt ``` ### Q3: Skills 在 Hermes 中不显示 @@ -281,7 +281,7 @@ pip3 uninstall weasyprint jinja2 markdown - **文档**: [TUTORIAL.md](docs/TUTORIAL.md) - **开发**: [DEVELOPMENT.md](docs/DEVELOPMENT.md) -- **测试**: `python3 tests/test_all.py` +- **测试**: `python -m pytest -q` - **FAQ**: [FAQ.md](FAQ.md) --- diff --git a/README.md b/README.md index 017b989..e6e2b0b 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,17 @@ python3 ~/.local/bin/gaokao-quick-3min.py 管理后台代码位于 `admin/`。当前已落地:服务启动、JWT 登录/鉴权、Swagger/OpenAPI、T6.2 仪表盘、T6.3 用户管理,以及 T6.4 订单管理(手工录单 / 状态流转 / CSV 导出 / 退款)。当前更准确的项目标签是“运营后台 + 人工服务增强链路”,不是完整用户端 Web 自助产品。 ```bash -# 安装管理后台依赖(与测试依赖分离) -pip install -r requirements-admin.txt -r requirements-dev.txt +# 先创建并激活项目虚拟环境 +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip + +# 安装管理后台依赖(统一受 constraints.txt 锁定) +pip install -c constraints.txt -r requirements-admin.txt -r requirements-dev.txt # 启动服务 -export GAOKAO_JWT_SECRET="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" -python3 -m admin.app --port 8000 +export GAOKAO_JWT_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')" +python -m admin.app --port 8000 # 验证 Swagger / OpenAPI curl http://127.0.0.1:8000/health @@ -144,7 +149,8 @@ xdg-open http://127.0.0.1:8000/docs 仓库已提供 `scripts/dev-verify.sh`,用于统一执行: - 创建/复用 `.venv` -- 安装 `requirements-admin.txt` + `requirements-dev.txt` +- 检查 `.venv/bin/python` 与 `PYTHON_BIN` 是否漂移 +- 按 `constraints.txt` 安装 `requirements-admin.txt` + `requirements-dev.txt` - 运行 `pytest` + coverage gate - 运行 `ruff` / `mypy` diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 0000000..a1e3527 --- /dev/null +++ b/constraints.txt @@ -0,0 +1,22 @@ +# Single source of truth for reproducible local/CI/container installs. +# Update with care; scripts/dev-verify.sh, CI, and Dockerfile all consume this file. +fastapi==0.133.1 +uvicorn==0.41.0 +PyJWT==2.10.1 +pydantic==2.13.4 +cryptography==45.0.7 +Jinja2==3.1.6 +weasyprint==66.0 +cairocffi==1.7.1 +python-multipart==0.0.32 +PyYAML==6.0.3 +pytest==9.1.0 +pytest-benchmark==5.2.3 +pytest-cov==7.1.0 +pytest-timeout==2.4.0 +pytest-xdist==3.8.0 +httpx==0.28.1 +locust==2.44.3 +ruff==0.15.17 +mypy==2.1.0 +types-PyYAML==6.0.12.20260518 diff --git a/docker-compose.yml b/docker-compose.yml index 5018c9a..562d117 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH: ${GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH:-} GAOKAO_OPS_ALERT_LOG: ${GAOKAO_OPS_ALERT_LOG:-/var/lib/gaokao/ops-alerts.jsonl} ports: - - "${GAOKAO_ADMIN_BIND:-127.0.0.1}:${GAOKAO_ADMIN_PORT:-8000}:8000" + - "${GAOKAO_ADMIN_BIND:-127.0.0.1}:${GAOKAO_ADMIN_PORT:-8000}:${GAOKAO_ADMIN_PORT:-8000}" volumes: - gaokao_admin_data:/var/lib/gaokao healthcheck: diff --git a/scripts/dev-verify.sh b/scripts/dev-verify.sh index 83a507d..71e48aa 100644 --- a/scripts/dev-verify.sh +++ b/scripts/dev-verify.sh @@ -22,11 +22,31 @@ log() { printf '[dev-verify] %s\n' "$1" } +python_version_of() { + "$1" --version 2>&1 | tr -d '\r' +} + +ensure_python_bin_matches_venv() { + if [[ ! -x "${VENV_DIR}/bin/python" ]]; then + return + fi + local venv_version + local target_version + venv_version="$(python_version_of "${VENV_DIR}/bin/python")" + target_version="$(python_version_of "${PYTHON_BIN}")" + if [[ "${venv_version}" != "${target_version}" ]]; then + log "python bin drift detected: venv=${venv_version} target=${target_version}" + log "remove ${VENV_DIR} and rerun, or point PYTHON_BIN at a matching interpreter" + return 1 + fi +} + ensure_venv() { if [[ ! -d "${VENV_DIR}" ]]; then log "creating venv at ${VENV_DIR}" "${PYTHON_BIN}" -m venv "${VENV_DIR}" fi + ensure_python_bin_matches_venv # shellcheck disable=SC1091 source "${VENV_DIR}/bin/activate" if ! python -m pip --version >/dev/null 2>&1; then @@ -47,7 +67,7 @@ install_requirements() { return fi log "installing requirements" - pip install -r "${ROOT_DIR}/requirements-admin.txt" -r "${ROOT_DIR}/requirements-dev.txt" + pip install -c "${ROOT_DIR}/constraints.txt" -r "${ROOT_DIR}/requirements-admin.txt" -r "${ROOT_DIR}/requirements-dev.txt" } run_checks() { diff --git a/tests/test_dev_verify_entrypoint.py b/tests/test_dev_verify_entrypoint.py index 82117f2..93c7c4d 100644 --- a/tests/test_dev_verify_entrypoint.py +++ b/tests/test_dev_verify_entrypoint.py @@ -172,3 +172,45 @@ printf '%s\n' "${{PRE_EXISTING_IGNORES[@]}}" assert ignores == [ "tests/test_t5_performance.py::test_admin_locust_10_concurrency_success_rate_above_95" ] + + +def test_dev_verify_detects_python_bin_drift(tmp_path: Path): + venv_dir = tmp_path / ".venv" + subprocess.run( + ["python3", "-m", "venv", str(venv_dir)], + check=True, + text=True, + capture_output=True, + ) + fake_python = tmp_path / "python-fake" + fake_python.write_text( + """#!/usr/bin/env bash +set -euo pipefail +if [[ \"${1:-}\" == \"--version\" ]]; then + echo \"Python 9.9.9\" + exit 0 +fi +exec /usr/bin/python3 \"$@\" +""", + encoding="utf-8", + ) + fake_python.chmod(0o755) + + probe = f""" +set -euo pipefail +export GAOKAO_SOURCE_ONLY=1 +source {SCRIPT} +VENV_DIR={venv_dir} +PYTHON_BIN={fake_python} +ensure_venv +""" + proc = subprocess.run( + [BASH, "-lc", probe], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + combined = proc.stdout + proc.stderr + assert proc.returncode != 0 + assert "python bin drift" in combined diff --git a/tests/test_docker_compose_env_contract.py b/tests/test_docker_compose_env_contract.py index f88b308..a453bf2 100644 --- a/tests/test_docker_compose_env_contract.py +++ b/tests/test_docker_compose_env_contract.py @@ -36,3 +36,11 @@ def test_docker_compose_passes_prod_critical_gaokao_env_vars() -> None: missing = sorted(required_keys - set(service_env)) assert not missing, f"docker-compose.yml missing gaokao env pass-through: {missing}" + + +def test_docker_compose_ports_follow_admin_bind_and_port() -> None: + compose = yaml.safe_load( + (PROJECT_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + ) + ports = compose["services"]["gaokao-admin"]["ports"] + assert ports == ["${GAOKAO_ADMIN_BIND:-127.0.0.1}:${GAOKAO_ADMIN_PORT:-8000}:${GAOKAO_ADMIN_PORT:-8000}"] diff --git a/tests/test_runtime_contracts.py b/tests/test_runtime_contracts.py new file mode 100644 index 0000000..43d0855 --- /dev/null +++ b/tests/test_runtime_contracts.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +CI_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "ci.yml" +DOCKERFILE = PROJECT_ROOT / "Dockerfile" +CONSTRAINTS = PROJECT_ROOT / "constraints.txt" + + +def test_ci_cache_key_tracks_all_runtime_requirement_inputs() -> None: + text = CI_WORKFLOW.read_text(encoding="utf-8") + assert "requirements-admin.txt" in text + assert "requirements-dev.txt" in text + assert "constraints.txt" in text + assert "hashFiles(" in text + + +def test_dockerfile_installs_with_constraints_and_runtime_env_contract() -> None: + text = DOCKERFILE.read_text(encoding="utf-8") + assert "constraints.txt" in text + assert "requirements-admin.txt" in text + assert 'GAOKAO_ADMIN_BIND' in text + assert 'GAOKAO_ADMIN_PORT' in text + + +def test_constraints_file_locks_runtime_and_dev_packages() -> None: + text = CONSTRAINTS.read_text(encoding="utf-8") + for needle in ( + "fastapi==", + "uvicorn==", + "weasyprint==", + "pytest==", + "ruff==", + "mypy==", + ): + assert needle in text