diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3a2849..a641f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,10 @@ jobs: run: | PYTHON_BIN=python bash scripts/dev-verify.sh + - name: Run PDF runtime smoke + run: | + python -m pytest -q tests/test_pdf_runtime_smoke.py + - name: Upload coverage artifact if: always() && matrix.python-version == '3.11' && hashFiles('coverage.xml') != '' uses: actions/upload-artifact@v4 diff --git a/Dockerfile b/Dockerfile index 022eb2e..0a72a07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ no_proxy= \ NO_PROXY= +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz-subset0 \ + libjpeg62-turbo \ + libopenjp2-7 \ + libffi8 \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY constraints.txt requirements-admin.txt /tmp/ diff --git a/INSTALL.md b/INSTALL.md index f3dfed4..4311b34 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -115,7 +115,17 @@ export QQ_EMAIL_PASSWORD="your-password" echo 'export QQ_EMAIL_PASSWORD="your-password"' >> ~/.bashrc ``` -### 3. 配置浏览器(可选) +### 3. PDF 运行时依赖(必看) + +当前仓库验证过的 PDF 生成口径: + +- 本地:激活 `.venv` 后执行 `python -m pytest -q tests/test_pdf_runtime_smoke.py` +- CI:workflow 会额外跑同一条 smoke +- Docker:`Dockerfile` 已安装 WeasyPrint 所需系统库 + +如果你的机器缺少系统库,WeasyPrint 可能安装成功但生成 PDF 失败。 + +### 4. 配置浏览器(可选) 用于生成PDF: @@ -202,16 +212,14 @@ hermes skills reload ### Q4: PDF 生成失败 -**原因**: weasyprint 依赖缺失 +**原因**: WeasyPrint 系统库缺失,或未跑过 PDF smoke **解决**: ```bash -# 安装系统依赖 -sudo apt-get install libpango-1.0-0 libpangoft2-1.0-0 - -# 重新安装 weasyprint -pip3 install --user --break-system-packages --force-reinstall weasyprint +sudo apt-get install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 +source .venv/bin/activate +python -m pytest -q tests/test_pdf_runtime_smoke.py ``` ### Q5: Git 提交失败 diff --git a/README.md b/README.md index e6e2b0b..0c7a104 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,16 @@ bash scripts/dev-verify.sh GAOKAO_SKIP_INSTALL=1 bash scripts/dev-verify.sh ``` +### PDF 运行时证明 + +当前仓库的 PDF 生成证明口径: + +- 本地 smoke:`./.venv/bin/python -m pytest -q tests/test_pdf_runtime_smoke.py` +- CI smoke:GitHub Actions 额外执行 `tests/test_pdf_runtime_smoke.py` +- Docker 运行时:`Dockerfile` 已显式安装 WeasyPrint 所需系统库(`libpango-1.0-0`、`libpangoft2-1.0-0`、`libharfbuzz-subset0` 等) + +这才表示“PDF 运行时已被交付门禁覆盖”;不是只靠开发机本地可跑。 + ### T12 交付事件最小执行链 当前 `report_ready` 不再只停留在事件表: diff --git a/admin/app.py b/admin/app.py index 137b46a..ca635ff 100644 --- a/admin/app.py +++ b/admin/app.py @@ -32,6 +32,7 @@ from admin.config import ( ) from admin.db import bootstrap_admin, ensure_schema from admin.errors import register_exception_handler +from data.orders.intake_store import SCHEMA_SQL as INTAKE_SCHEMA_SQL from admin.logging_utils import ( bind_request_context, clear_request_context, @@ -98,6 +99,8 @@ def _setup_database(settings: Settings) -> None: cases_conn = apply_cases_schema(settings.db_path) cases_conn.close() orders_conn = apply_orders_schema(settings.orders_db_path) + orders_conn.executescript(INTAKE_SCHEMA_SQL) + orders_conn.commit() orders_conn.close() created, msg = bootstrap_admin(settings) if created: diff --git a/admin/routes/notifications.py b/admin/routes/notifications.py index 51dcfa3..4a0f2fa 100644 --- a/admin/routes/notifications.py +++ b/admin/routes/notifications.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, Query from fastapi.responses import HTMLResponse from pydantic import BaseModel -from admin.auth import get_current_user, require_role +from admin.auth import require_role from admin.config import Settings, get_settings_dep from admin.db import AdminUser diff --git a/admin/routes/orders.py b/admin/routes/orders.py index 5ebfaff..a45c97b 100644 --- a/admin/routes/orders.py +++ b/admin/routes/orders.py @@ -20,7 +20,7 @@ from typing import Any, Literal, Optional, cast from fastapi import APIRouter, Depends, Path as ApiPath, Query, Response, status from pydantic import BaseModel, Field -from admin.auth import get_current_user, require_role +from admin.auth import require_role from admin.config import Settings, get_settings_dep from admin.db import AdminUser from admin.errors import ( diff --git a/admin/routes/users.py b/admin/routes/users.py index 4a8389a..225c09f 100644 --- a/admin/routes/users.py +++ b/admin/routes/users.py @@ -12,7 +12,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, Path, Query from pydantic import BaseModel -from admin.auth import get_current_user, require_role +from admin.auth import require_role from admin.config import Settings, get_settings_dep from admin.db import AdminUser from admin.errors import DATA_NOT_FOUND diff --git a/admin/tests/test_order_status_page.py b/admin/tests/test_order_status_page.py index 3148806..8b5f29a 100644 --- a/admin/tests/test_order_status_page.py +++ b/admin/tests/test_order_status_page.py @@ -58,8 +58,10 @@ def test_order_status_page_and_report_download(client, settings, tmp_path: Path) submit=True, ) - report_path = tmp_path / "report.html" - pdf_path = tmp_path / "report.pdf" + report_root = Path(settings.share_report_dir) + report_root.mkdir(parents=True, exist_ok=True) + report_path = report_root / "report.html" + pdf_path = report_root / "report.pdf" report_path.write_text("

志愿方案报告

已生成。

", encoding="utf-8") pdf_path.write_bytes(b"%PDF-1.4\nportal-report\n") @@ -99,8 +101,10 @@ def test_portal_status_page_shows_sent_station_notification( order_id=order.id, payload={"candidate_score": 578}, submit=True ) - report_path = tmp_path / "status-notice-report.html" - pdf_path = tmp_path / "status-notice-report.pdf" + report_root = Path(settings.share_report_dir) + report_root.mkdir(parents=True, exist_ok=True) + report_path = report_root / "status-notice-report.html" + pdf_path = report_root / "status-notice-report.pdf" report_path.write_text("

志愿方案报告

已生成。

", encoding="utf-8") pdf_path.write_bytes(b"%PDF-1.4\nportal-notice\n") @@ -192,7 +196,9 @@ def test_partial_artifacts_do_not_expose_delivery_links_before_report_ready( submit=True, ) - report_path = tmp_path / "partial-report.html" + report_root = Path(settings.share_report_dir) + report_root.mkdir(parents=True, exist_ok=True) + report_path = report_root / "partial-report.html" report_path.write_text("

partial

", encoding="utf-8") with OrdersDAO.connect(settings.orders_db_path) as dao: dao.update( diff --git a/admin/tests/test_web_public_alipay_sim_e2e.py b/admin/tests/test_web_public_alipay_sim_e2e.py index 84a0348..e2c1687 100644 --- a/admin/tests/test_web_public_alipay_sim_e2e.py +++ b/admin/tests/test_web_public_alipay_sim_e2e.py @@ -68,11 +68,13 @@ def test_alipay_sim_public_user_e2e_flow(tmp_path, monkeypatch): assert pay_resp.status_code == 303, pay_resp.text # After successful payment, the gateway now lands users on an # intermediate "payment-success" page that explains next steps - # before they continue to the live status view. Verify the - # intermediate page renders, then the E2E flow continues into - # the status page below. - assert pay_resp.headers["location"].endswith(f"/portal/{token}/payment-success") - success_page = client.get(f"/portal/{token}/payment-success") + # before they continue to the live status view. Redirect token + # may be freshly signed, so only assert the route shape and then + # follow the concrete redirected location. + success_path = pay_resp.headers["location"] + assert success_path.startswith("/portal/") + assert success_path.endswith("/payment-success") + success_page = client.get(success_path) assert success_page.status_code == 200, success_page.text assert "支付成功" in success_page.text diff --git a/data/payments/tests/test_service.py b/data/payments/tests/test_service.py index a0fc72f..4bca0cf 100644 --- a/data/payments/tests/test_service.py +++ b/data/payments/tests/test_service.py @@ -7,7 +7,6 @@ import pytest from data.orders.dao import OrdersDAO from data.payments.dao import PaymentDAO from data.orders.models import Order -from data.payments.models import PaymentRecord from data.payments.service import PaymentService @@ -278,18 +277,6 @@ def test_failed_payment_state_is_not_rendered_as_portal_stage(settings): from admin.routes.web_public import _build_portal_context - failed_payment = PaymentRecord( - id=payment.id, - order_id=payment.order_id, - provider=payment.provider, - amount_cents=payment.amount_cents, - currency=payment.currency, - status="failed", - checkout_token=payment.checkout_token, - callback_payload=payment.callback_payload, - created_at=payment.created_at, - updated_at=payment.updated_at, - ) dao = PaymentDAO.for_db(settings.orders_db_path) try: dao.update_status(payment.id, status="failed") diff --git a/scripts/backup_restore_smoke.py b/scripts/backup_restore_smoke.py index 8a428ba..01dd6c9 100644 --- a/scripts/backup_restore_smoke.py +++ b/scripts/backup_restore_smoke.py @@ -25,11 +25,21 @@ class RestorePreconditionError(RuntimeError): def _choose_restore_file(backup_dir: Path, suffix: str) -> Path | None: - candidates = sorted( - path - for path in (backup_dir / "files").rglob(f"*{suffix}") - if path.is_file() and "__pycache__" not in path.parts - ) + search_roots = [ + backup_dir / "files" / "order_artifacts", + backup_dir / "files" / "reports", + backup_dir / "files", + ] + seen: set[Path] = set() + candidates: list[Path] = [] + for root in search_roots: + if not root.exists(): + continue + for path in sorted(root.rglob(f"*{suffix}")): + if not path.is_file() or "__pycache__" in path.parts or path in seen: + continue + seen.add(path) + candidates.append(path) return candidates[0] if candidates else None @@ -128,6 +138,7 @@ def run_restore_smoke(backup_dir: str | Path) -> dict[str, object]: "GAOKAO_ORDERS_DB_PATH": str(orders_db), "GAOKAO_SHARE_DB_PATH": str(share_db or (root / "db" / "short_links.db")), "GAOKAO_SHARE_REPORT_DIR": str(share_report_dir), + "GAOKAO_PORTAL_UPLOAD_DIR": str(root / "files" / "portal_uploads"), "GAOKAO_ORDERS_FERNET_KEY": os.environ.get( "GAOKAO_ORDERS_FERNET_KEY", DEFAULT_FERNET_SECRET ), @@ -212,7 +223,7 @@ def run_restore_smoke(backup_dir: str | Path) -> dict[str, object]: report_page = report_view_page(token, settings) report_body = bytes(report_page.body).decode("utf-8") - if report_page.status_code != 200 or " None: + from weasyprint import HTML # type: ignore[import-untyped] + + out = tmp_path / "smoke.pdf" + HTML(string="

smoke

").write_pdf(out) + + assert out.exists() + assert out.stat().st_size > 0 + + +def test_ci_includes_pdf_runtime_smoke_gate() -> None: + text = (PROJECT_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + assert "tests/test_pdf_runtime_smoke.py" in text + + +def test_dockerfile_installs_pdf_runtime_system_libs() -> None: + text = (PROJECT_ROOT / "Dockerfile").read_text(encoding="utf-8") + for needle in ( + "apt-get update", + "libpango-1.0-0", + "libpangoft2-1.0-0", + "libharfbuzz-subset0", + ): + assert needle in text + + +def test_dev_verify_skip_pre_existing_still_mentions_locust_probe_only() -> None: + text = (PROJECT_ROOT / "scripts" / "dev-verify.sh").read_text(encoding="utf-8") + assert "test_admin_locust_10_concurrency_success_rate_above_95" in text