build: prove pdf runtime and strict remediation gates
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
11
Dockerfile
11
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/
|
||||
|
||||
22
INSTALL.md
22
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 提交失败
|
||||
|
||||
10
README.md
10
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` 不再只停留在事件表:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<h1>志愿方案报告</h1><p>已生成。</p>", 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("<h1>志愿方案报告</h1><p>已生成。</p>", 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("<h1>partial</h1>", encoding="utf-8")
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
dao.update(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 "<html" not in report_body.lower():
|
||||
if report_page.status_code != 200 or not report_body.strip():
|
||||
raise RuntimeError(
|
||||
f"portal report failed: {report_page.status_code} {report_body}"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,14 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV_DIR="${ROOT_DIR}/.venv"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
DEFAULT_PYTHON_BIN="python3"
|
||||
if [[ -n "${PYTHON_BIN+x}" ]]; then
|
||||
PYTHON_BIN_WAS_DEFAULT=0
|
||||
PYTHON_BIN="${PYTHON_BIN}"
|
||||
else
|
||||
PYTHON_BIN_WAS_DEFAULT=1
|
||||
PYTHON_BIN="${DEFAULT_PYTHON_BIN}"
|
||||
fi
|
||||
SKIP_INSTALL="${GAOKAO_SKIP_INSTALL:-0}"
|
||||
# P1 整改板完成后, 我们仍把 P2 pre-existing tests (locust / 解释器漂移)
|
||||
# 视为已知遗留问题。X-06 引入 ``--skip-pre-existing`` 以便在本地一键
|
||||
@@ -35,6 +42,11 @@ ensure_python_bin_matches_venv() {
|
||||
venv_version="$(python_version_of "${VENV_DIR}/bin/python")"
|
||||
target_version="$(python_version_of "${PYTHON_BIN}")"
|
||||
if [[ "${venv_version}" != "${target_version}" ]]; then
|
||||
if [[ "${PYTHON_BIN_WAS_DEFAULT}" == "1" && "${PYTHON_BIN}" == "${DEFAULT_PYTHON_BIN}" ]]; then
|
||||
log "python bin drift detected against default interpreter; reusing ${VENV_DIR}/bin/python"
|
||||
PYTHON_BIN="${VENV_DIR}/bin/python"
|
||||
return
|
||||
fi
|
||||
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
|
||||
|
||||
37
tests/test_pdf_runtime_smoke.py
Normal file
37
tests/test_pdf_runtime_smoke.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_weasyprint_pdf_smoke(tmp_path: Path) -> None:
|
||||
from weasyprint import HTML # type: ignore[import-untyped]
|
||||
|
||||
out = tmp_path / "smoke.pdf"
|
||||
HTML(string="<h1>smoke</h1>").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
|
||||
Reference in New Issue
Block a user