2026-06-13 14:49:58 +08:00
|
|
|
|
"""pytest 配置 (T6.1).
|
|
|
|
|
|
|
|
|
|
|
|
提供 fixture:
|
|
|
|
|
|
- settings: 内存 SQLite + 安全 JWT 密钥 + 短过期
|
|
|
|
|
|
- app: FastAPI 实例(lifespan 已运行 → admin 表已建 + bootstrap 用户)
|
|
|
|
|
|
- client: httpx TestClient
|
|
|
|
|
|
- auth_token: 登录后的 Bearer JWT
|
|
|
|
|
|
- auth_headers: {"Authorization": f"Bearer ..."}
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-06-18 10:21:38 +08:00
|
|
|
|
import json
|
2026-06-13 14:49:58 +08:00
|
|
|
|
import sys
|
2026-06-18 10:21:38 +08:00
|
|
|
|
from dataclasses import dataclass
|
2026-06-13 14:49:58 +08:00
|
|
|
|
from pathlib import Path
|
2026-06-24 22:30:22 +08:00
|
|
|
|
from typing import cast, Literal
|
2026-06-18 10:21:38 +08:00
|
|
|
|
from urllib.parse import parse_qsl, urlsplit
|
2026-06-13 14:49:58 +08:00
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-06-18 10:21:38 +08:00
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
from starlette.requests import Request
|
2026-06-13 14:49:58 +08:00
|
|
|
|
|
|
|
|
|
|
# 确保项目根在 sys.path
|
|
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
|
|
|
|
if str(_ROOT) not in sys.path:
|
|
|
|
|
|
sys.path.insert(0, str(_ROOT))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 10:21:38 +08:00
|
|
|
|
@dataclass
|
|
|
|
|
|
class RouteResponse:
|
|
|
|
|
|
status_code: int
|
|
|
|
|
|
headers: dict[str, str]
|
|
|
|
|
|
text: str
|
|
|
|
|
|
_json: object | None = None
|
|
|
|
|
|
|
|
|
|
|
|
def json(self):
|
|
|
|
|
|
if self._json is None:
|
|
|
|
|
|
raise ValueError("response does not contain json payload")
|
|
|
|
|
|
return self._json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RouteClient:
|
|
|
|
|
|
"""基于直接路由调用的轻量测试 client,绕开当前环境中的 TestClient 挂起。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, app):
|
|
|
|
|
|
self.app = app
|
|
|
|
|
|
self._started = False
|
|
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
|
if not self._started:
|
|
|
|
|
|
from admin.app import _setup_database, _validate_and_log_settings
|
|
|
|
|
|
|
|
|
|
|
|
settings = self.app.state.settings
|
|
|
|
|
|
_validate_and_log_settings(settings)
|
|
|
|
|
|
_setup_database(settings)
|
|
|
|
|
|
self._started = True
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-06-24 22:30:22 +08:00
|
|
|
|
def _request(
|
|
|
|
|
|
path: str, *, method: str = "GET", headers: dict[str, str] | None = None
|
|
|
|
|
|
):
|
2026-06-18 10:21:38 +08:00
|
|
|
|
split = urlsplit(path)
|
|
|
|
|
|
raw_headers = [
|
|
|
|
|
|
(key.lower().encode("utf-8"), value.encode("utf-8"))
|
|
|
|
|
|
for key, value in (headers or {}).items()
|
|
|
|
|
|
]
|
2026-06-28 12:11:43 +08:00
|
|
|
|
return Request({
|
|
|
|
|
|
"type": "http",
|
|
|
|
|
|
"method": method,
|
|
|
|
|
|
"path": split.path,
|
|
|
|
|
|
"query_string": split.query.encode("utf-8"),
|
|
|
|
|
|
"headers": raw_headers,
|
|
|
|
|
|
})
|
2026-06-18 10:21:38 +08:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _html_response(response) -> RouteResponse:
|
|
|
|
|
|
return RouteResponse(
|
|
|
|
|
|
status_code=response.status_code,
|
|
|
|
|
|
headers=dict(response.headers),
|
|
|
|
|
|
text=response.body.decode("utf-8"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _json_response(
|
|
|
|
|
|
payload: object,
|
|
|
|
|
|
*,
|
|
|
|
|
|
status_code: int = 200,
|
|
|
|
|
|
headers: dict[str, str] | None = None,
|
|
|
|
|
|
) -> RouteResponse:
|
|
|
|
|
|
return RouteResponse(
|
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
|
headers=headers or {"content-type": "application/json"},
|
|
|
|
|
|
text=json.dumps(payload, ensure_ascii=False),
|
|
|
|
|
|
_json=payload,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _redirect_response(response) -> RouteResponse:
|
|
|
|
|
|
return RouteResponse(
|
|
|
|
|
|
status_code=response.status_code,
|
|
|
|
|
|
headers=dict(response.headers),
|
|
|
|
|
|
text="",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _http_exception_response(exc: HTTPException) -> RouteResponse:
|
|
|
|
|
|
detail = exc.detail
|
|
|
|
|
|
if isinstance(detail, str):
|
|
|
|
|
|
payload = {"detail": {"reason": detail}}
|
|
|
|
|
|
else:
|
|
|
|
|
|
payload = {"detail": detail}
|
|
|
|
|
|
return RouteClient._json_response(
|
|
|
|
|
|
payload,
|
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _validation_error_response(exc: ValidationError) -> RouteResponse:
|
|
|
|
|
|
from admin.errors.codes import DATA_VALIDATION_FAILED
|
|
|
|
|
|
from admin.errors.registry import get_message
|
|
|
|
|
|
|
|
|
|
|
|
msg = get_message(str(DATA_VALIDATION_FAILED))
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"code": str(DATA_VALIDATION_FAILED),
|
|
|
|
|
|
"message": msg.message,
|
|
|
|
|
|
"suggestion": msg.suggestion,
|
|
|
|
|
|
"severity": msg.severity,
|
|
|
|
|
|
"retryable": msg.retryable,
|
|
|
|
|
|
"detail": {
|
|
|
|
|
|
"fields": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"field": "body." + ".".join(str(part) for part in err["loc"]),
|
|
|
|
|
|
"reason": err["msg"],
|
|
|
|
|
|
}
|
|
|
|
|
|
for err in exc.errors()
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
return RouteClient._json_response(payload, status_code=422)
|
|
|
|
|
|
|
|
|
|
|
|
def get(self, path: str, **kwargs):
|
|
|
|
|
|
from admin.routes.web_public import (
|
|
|
|
|
|
ServiceVersion,
|
|
|
|
|
|
checkout_page,
|
|
|
|
|
|
deletion_policy_page,
|
|
|
|
|
|
order_info_page,
|
|
|
|
|
|
order_status_page,
|
|
|
|
|
|
payment_return_page,
|
|
|
|
|
|
payment_success_page,
|
|
|
|
|
|
pricing_page,
|
|
|
|
|
|
privacy_page,
|
2026-06-18 14:28:04 +08:00
|
|
|
|
report_pdf_download,
|
|
|
|
|
|
report_view_page,
|
2026-06-24 22:30:22 +08:00
|
|
|
|
review_start_page,
|
2026-06-18 10:21:38 +08:00
|
|
|
|
service_terms_page,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
split = urlsplit(path)
|
|
|
|
|
|
route_path = split.path
|
|
|
|
|
|
settings = self.app.state.settings
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
if route_path == "/":
|
2026-06-24 22:30:22 +08:00
|
|
|
|
request = self._request(path, method="GET")
|
|
|
|
|
|
from admin.routes.web_public import landing_page
|
|
|
|
|
|
|
|
|
|
|
|
return self._html_response(landing_page(request, settings))
|
2026-06-18 10:21:38 +08:00
|
|
|
|
if route_path == "/pricing":
|
2026-06-24 22:30:22 +08:00
|
|
|
|
request = self._request(path, method="GET")
|
|
|
|
|
|
return self._html_response(pricing_page(request))
|
2026-06-18 10:21:38 +08:00
|
|
|
|
if route_path.startswith("/checkout/"):
|
|
|
|
|
|
service_version = cast(
|
|
|
|
|
|
ServiceVersion, route_path.split("/checkout/", 1)[1]
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._html_response(checkout_page(service_version))
|
|
|
|
|
|
if route_path == "/portal/payment-return":
|
|
|
|
|
|
query = dict(parse_qsl(split.query, keep_blank_values=True))
|
|
|
|
|
|
return self._redirect_response(
|
2026-06-18 15:04:46 +08:00
|
|
|
|
payment_return_page(query["payment_id"], settings)
|
2026-06-18 10:21:38 +08:00
|
|
|
|
)
|
2026-06-24 22:30:22 +08:00
|
|
|
|
if route_path == "/review/start":
|
|
|
|
|
|
query = dict(parse_qsl(split.query, keep_blank_values=True))
|
|
|
|
|
|
return self._html_response(
|
|
|
|
|
|
review_start_page(
|
|
|
|
|
|
cast(
|
|
|
|
|
|
Literal["home", "status", "report", "direct"],
|
|
|
|
|
|
query.get("source") or "direct",
|
|
|
|
|
|
),
|
|
|
|
|
|
query.get("token"),
|
|
|
|
|
|
query.get("province"),
|
|
|
|
|
|
query.get("score"),
|
|
|
|
|
|
query.get("goal"),
|
|
|
|
|
|
query.get("consult"),
|
|
|
|
|
|
settings,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
if route_path.startswith("/portal/") and route_path.endswith(
|
|
|
|
|
|
"/payment-success"
|
|
|
|
|
|
):
|
|
|
|
|
|
token = route_path.split("/portal/", 1)[1].rsplit(
|
|
|
|
|
|
"/payment-success", 1
|
|
|
|
|
|
)[0]
|
2026-06-18 10:21:38 +08:00
|
|
|
|
return self._html_response(payment_success_page(token, settings))
|
|
|
|
|
|
if route_path.startswith("/portal/") and route_path.endswith("/status"):
|
|
|
|
|
|
token = route_path.split("/portal/", 1)[1].rsplit("/status", 1)[0]
|
|
|
|
|
|
return self._html_response(order_status_page(token, settings))
|
|
|
|
|
|
if route_path.startswith("/portal/") and route_path.endswith("/info"):
|
|
|
|
|
|
token = route_path.split("/portal/", 1)[1].rsplit("/info", 1)[0]
|
|
|
|
|
|
return self._html_response(order_info_page(token, settings))
|
2026-06-24 22:30:22 +08:00
|
|
|
|
if route_path.startswith("/portal/") and route_path.endswith("/cwb"):
|
|
|
|
|
|
token = route_path.split("/portal/", 1)[1].rsplit("/cwb", 1)[0]
|
|
|
|
|
|
from admin.routes.web_public import cwb_placeholder_page
|
|
|
|
|
|
|
|
|
|
|
|
return self._html_response(cwb_placeholder_page(token, settings))
|
|
|
|
|
|
if route_path.startswith("/portal/") and route_path.endswith("/full-plan"):
|
|
|
|
|
|
token = route_path.split("/portal/", 1)[1].rsplit("/full-plan", 1)[0]
|
|
|
|
|
|
from admin.routes.web_public import full_plan_placeholder_page
|
|
|
|
|
|
|
|
|
|
|
|
return self._html_response(full_plan_placeholder_page(token, settings))
|
2026-06-18 14:28:04 +08:00
|
|
|
|
if route_path.startswith("/portal/") and route_path.endswith("/report.pdf"):
|
|
|
|
|
|
token = route_path.split("/portal/", 1)[1].rsplit("/report.pdf", 1)[0]
|
|
|
|
|
|
response = report_pdf_download(token, settings)
|
|
|
|
|
|
return RouteResponse(
|
|
|
|
|
|
status_code=response.status_code,
|
|
|
|
|
|
headers=dict(response.headers),
|
|
|
|
|
|
text="",
|
|
|
|
|
|
)
|
|
|
|
|
|
if route_path.startswith("/portal/") and route_path.endswith("/report"):
|
|
|
|
|
|
token = route_path.split("/portal/", 1)[1].rsplit("/report", 1)[0]
|
|
|
|
|
|
return self._html_response(report_view_page(token, settings))
|
2026-06-18 10:21:38 +08:00
|
|
|
|
if route_path == "/privacy":
|
|
|
|
|
|
query = dict(parse_qsl(split.query, keep_blank_values=True))
|
|
|
|
|
|
return self._html_response(privacy_page(query.get("token")))
|
|
|
|
|
|
if route_path == "/service-terms":
|
|
|
|
|
|
query = dict(parse_qsl(split.query, keep_blank_values=True))
|
|
|
|
|
|
return self._html_response(service_terms_page(query.get("token")))
|
2026-06-24 22:30:22 +08:00
|
|
|
|
if route_path == "/policy-center":
|
|
|
|
|
|
query = dict(parse_qsl(split.query, keep_blank_values=True))
|
|
|
|
|
|
from admin.routes.web_public import policy_center_page
|
|
|
|
|
|
|
|
|
|
|
|
return self._html_response(
|
|
|
|
|
|
policy_center_page(query.get("province") or "湖南")
|
|
|
|
|
|
)
|
|
|
|
|
|
if route_path == "/same-score-reference":
|
|
|
|
|
|
query = dict(parse_qsl(split.query, keep_blank_values=True))
|
|
|
|
|
|
from admin.routes.web_public import same_score_reference_page
|
|
|
|
|
|
|
|
|
|
|
|
score = int(query.get("score") or 0)
|
|
|
|
|
|
return self._html_response(
|
|
|
|
|
|
same_score_reference_page(query.get("province") or "湖南", score)
|
|
|
|
|
|
)
|
2026-06-18 10:21:38 +08:00
|
|
|
|
if route_path == "/deletion-policy":
|
|
|
|
|
|
return self._html_response(deletion_policy_page())
|
|
|
|
|
|
except HTTPException as exc:
|
|
|
|
|
|
return self._http_exception_response(exc)
|
|
|
|
|
|
|
|
|
|
|
|
raise NotImplementedError(f"unsupported GET path in RouteClient: {path}")
|
|
|
|
|
|
|
|
|
|
|
|
def post(self, path: str, **kwargs):
|
|
|
|
|
|
from admin.routes.web_public import (
|
|
|
|
|
|
complete_mock_payment,
|
|
|
|
|
|
create_public_order_endpoint,
|
|
|
|
|
|
mock_payment_webhook,
|
2026-06-24 22:30:22 +08:00
|
|
|
|
review_action_endpoint,
|
2026-06-18 10:21:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
from data.orders.public_flow import PublicOrderCreate
|
|
|
|
|
|
|
|
|
|
|
|
split = urlsplit(path)
|
|
|
|
|
|
route_path = split.path
|
|
|
|
|
|
settings = self.app.state.settings
|
|
|
|
|
|
payload = kwargs.get("json")
|
2026-06-24 22:30:22 +08:00
|
|
|
|
form_data = kwargs.get("data") or {}
|
2026-06-18 10:21:38 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
if route_path == "/api/public/orders":
|
|
|
|
|
|
try:
|
|
|
|
|
|
model = PublicOrderCreate.model_validate(payload or {})
|
|
|
|
|
|
except ValidationError as exc:
|
|
|
|
|
|
return self._validation_error_response(exc)
|
|
|
|
|
|
created = create_public_order_endpoint(model, settings)
|
|
|
|
|
|
return self._json_response(created.model_dump(), status_code=201)
|
|
|
|
|
|
if route_path.startswith("/pay/mock/") and route_path.endswith("/complete"):
|
2026-06-24 22:30:22 +08:00
|
|
|
|
payment_id = route_path.split("/pay/mock/", 1)[1].rsplit(
|
|
|
|
|
|
"/complete", 1
|
|
|
|
|
|
)[0]
|
2026-06-18 15:04:46 +08:00
|
|
|
|
redirect = complete_mock_payment(payment_id, settings)
|
2026-06-18 10:21:38 +08:00
|
|
|
|
return self._redirect_response(redirect)
|
|
|
|
|
|
if route_path == "/api/public/payments/mock/webhook":
|
2026-06-24 22:30:22 +08:00
|
|
|
|
request = self._request(
|
|
|
|
|
|
path, method="POST", headers=kwargs.get("headers")
|
|
|
|
|
|
)
|
2026-06-18 10:21:38 +08:00
|
|
|
|
ack = mock_payment_webhook(payload or {}, request, settings)
|
|
|
|
|
|
return self._json_response(ack.model_dump())
|
2026-06-24 22:30:22 +08:00
|
|
|
|
if route_path == "/review/action":
|
|
|
|
|
|
result = review_action_endpoint(
|
|
|
|
|
|
token=str(
|
|
|
|
|
|
(payload or {}).get("token") or form_data.get("token") or ""
|
|
|
|
|
|
),
|
|
|
|
|
|
action=cast(
|
|
|
|
|
|
Literal["cwb", "step1", "full_plan"],
|
|
|
|
|
|
str(
|
|
|
|
|
|
(payload or {}).get("action")
|
|
|
|
|
|
or form_data.get("action")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
settings=settings,
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._redirect_response(result)
|
2026-06-18 10:21:38 +08:00
|
|
|
|
except HTTPException as exc:
|
|
|
|
|
|
return self._http_exception_response(exc)
|
|
|
|
|
|
|
|
|
|
|
|
raise NotImplementedError(f"unsupported POST path in RouteClient: {path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 14:49:58 +08:00
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def secure_secret() -> str:
|
|
|
|
|
|
"""64-char 安全 JWT 密钥。"""
|
|
|
|
|
|
return "x" * 64 # 确定性,便于测试断言
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def settings(tmp_path, secure_secret, monkeypatch):
|
|
|
|
|
|
"""隔离的 Settings 实例:tmp_path SQLite + 安全密钥 + 短过期。
|
|
|
|
|
|
|
|
|
|
|
|
用真实文件而不是 :memory: 是因为 admin/db.py 中每次 get_connection
|
|
|
|
|
|
都新建连接,:memory: 在 SQLite 下不共享状态。
|
|
|
|
|
|
"""
|
|
|
|
|
|
db_path = str(tmp_path / "admin.db")
|
|
|
|
|
|
orders_db_path = str(tmp_path / "orders.db")
|
|
|
|
|
|
share_db_path = str(tmp_path / "short_links.db")
|
|
|
|
|
|
share_report_dir = str(tmp_path / "share_reports")
|
2026-06-15 16:26:26 +08:00
|
|
|
|
portal_upload_dir = str(tmp_path / "portal_uploads")
|
2026-06-15 19:01:21 +08:00
|
|
|
|
ops_alert_log = str(tmp_path / "ops-alerts.jsonl")
|
2026-06-15 21:24:55 +08:00
|
|
|
|
deletion_request_log = str(tmp_path / "deletion-requests.jsonl")
|
2026-06-13 14:49:58 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_ENV", "dev")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_DB_PATH", db_path)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", share_db_path)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", share_report_dir)
|
2026-06-15 16:26:26 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_DIR", portal_upload_dir)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_MAX_BYTES", "5242880")
|
2026-06-15 19:01:21 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_MAX_FILES", "5")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_OPS_ALERT_LOG", ops_alert_log)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ALERT_RECIPIENTS", "")
|
2026-06-15 19:36:47 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_ALERT_WEBHOOK_URLS", "")
|
2026-06-14 05:26:27 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
|
2026-06-13 14:49:58 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret)
|
2026-06-28 12:11:43 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_LLM_PROVIDER", "dashscope")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_LLM_API_KEY", "sk-test")
|
2026-06-13 14:49:58 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
2026-06-15 21:24:55 +08:00
|
|
|
|
monkeypatch.setenv("GAOKAO_OPS_ALERT_LOG", ops_alert_log)
|
|
|
|
|
|
monkeypatch.setenv("GAOKAO_DELETION_REQUEST_LOG", deletion_request_log)
|
2026-06-13 14:49:58 +08:00
|
|
|
|
|
|
|
|
|
|
from admin.config import load_settings
|
|
|
|
|
|
|
|
|
|
|
|
return load_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def orders_db(settings):
|
|
|
|
|
|
"""T6.2 起:管理后台统计端点会读 orders 表,因此 conftest 顺带建一个
|
|
|
|
|
|
空 orders DB (T4.1 schema),后续 dashboard 测试可在里面塞 fixture 数据。
|
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions
## 后端 admin/stats.py
- compute_summary 在单条聚合 SQL 中加两个新字段:
- pending_overdue_24h: status='pending' AND created_at < (now - 24h)
(超时未付,需主动催付)
- pending_missing_intake: status='pending' AND
(order_intakes 无记录 OR order_intakes.status='draft')
(资料待补,需主动跟进)
- LEFT JOIN order_intakes AS i ON i.order_id = orders.id
- 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配,
(overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES)
导致 overdue_cutoff datetime 字符串误入 IN 子句,
'completed' 误入 created_at < ? 子句, 收入少算 30%。
已修正为 (*_REVENUE_STATUSES, overdue_cutoff)
- 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义
## 前端 admin/static/dashboard.html
- KPI 4 待处理订单卡加 .pending-breakdown 区域
- 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补)
- 仅在对应字段 > 0 时才显示
- CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效
## 前端 admin/static/dashboard.js
- renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染
- 至少一个非零才展开 breakdown 区域
## 测试
- conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表
- test_routes_stats_dashboard.py:
- test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段
- test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入)
- 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前)
- 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合)
- test_app.py 加 pending-breakdown 结构断言
- pytest 52 passed
## 真实环境验证
- admin/test-pass-123 登录后:
pending=7 overdue=0 missing_intake=7
- 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill
'超时' pill 因 overdue=0 自动隐藏
- 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
|
|
|
|
|
|
|
|
|
|
T-pending-intake 起: 同时建 order_intakes 表,确保 summary.pending_missing_intake
|
|
|
|
|
|
的 LEFT JOIN 不会因为缺表而失败。
|
2026-06-13 14:49:58 +08:00
|
|
|
|
"""
|
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions
## 后端 admin/stats.py
- compute_summary 在单条聚合 SQL 中加两个新字段:
- pending_overdue_24h: status='pending' AND created_at < (now - 24h)
(超时未付,需主动催付)
- pending_missing_intake: status='pending' AND
(order_intakes 无记录 OR order_intakes.status='draft')
(资料待补,需主动跟进)
- LEFT JOIN order_intakes AS i ON i.order_id = orders.id
- 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配,
(overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES)
导致 overdue_cutoff datetime 字符串误入 IN 子句,
'completed' 误入 created_at < ? 子句, 收入少算 30%。
已修正为 (*_REVENUE_STATUSES, overdue_cutoff)
- 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义
## 前端 admin/static/dashboard.html
- KPI 4 待处理订单卡加 .pending-breakdown 区域
- 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补)
- 仅在对应字段 > 0 时才显示
- CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效
## 前端 admin/static/dashboard.js
- renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染
- 至少一个非零才展开 breakdown 区域
## 测试
- conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表
- test_routes_stats_dashboard.py:
- test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段
- test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入)
- 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前)
- 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合)
- test_app.py 加 pending-breakdown 结构断言
- pytest 52 passed
## 真实环境验证
- admin/test-pass-123 登录后:
pending=7 overdue=0 missing_intake=7
- 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill
'超时' pill 因 overdue=0 自动隐藏
- 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
|
|
|
|
from data.orders.intake_store import SCHEMA_SQL as INTAKE_SCHEMA_SQL
|
2026-06-13 14:49:58 +08:00
|
|
|
|
from data.orders.schema import apply_schema
|
|
|
|
|
|
|
|
|
|
|
|
conn = apply_schema(settings.orders_db_path)
|
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions
## 后端 admin/stats.py
- compute_summary 在单条聚合 SQL 中加两个新字段:
- pending_overdue_24h: status='pending' AND created_at < (now - 24h)
(超时未付,需主动催付)
- pending_missing_intake: status='pending' AND
(order_intakes 无记录 OR order_intakes.status='draft')
(资料待补,需主动跟进)
- LEFT JOIN order_intakes AS i ON i.order_id = orders.id
- 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配,
(overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES)
导致 overdue_cutoff datetime 字符串误入 IN 子句,
'completed' 误入 created_at < ? 子句, 收入少算 30%。
已修正为 (*_REVENUE_STATUSES, overdue_cutoff)
- 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义
## 前端 admin/static/dashboard.html
- KPI 4 待处理订单卡加 .pending-breakdown 区域
- 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补)
- 仅在对应字段 > 0 时才显示
- CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效
## 前端 admin/static/dashboard.js
- renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染
- 至少一个非零才展开 breakdown 区域
## 测试
- conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表
- test_routes_stats_dashboard.py:
- test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段
- test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入)
- 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前)
- 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合)
- test_app.py 加 pending-breakdown 结构断言
- pytest 52 passed
## 真实环境验证
- admin/test-pass-123 登录后:
pending=7 overdue=0 missing_intake=7
- 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill
'超时' pill 因 overdue=0 自动隐藏
- 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
|
|
|
|
conn.executescript(INTAKE_SCHEMA_SQL)
|
|
|
|
|
|
conn.commit()
|
2026-06-13 14:49:58 +08:00
|
|
|
|
conn.close()
|
|
|
|
|
|
return settings.orders_db_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def _auto_orders_db(settings, orders_db):
|
|
|
|
|
|
"""所有测试都默认有空 orders DB 可用,避免 T6.2 端点在不相关测试里
|
|
|
|
|
|
报 'no such table: orders'。``orders_db`` 显式依赖确保已建。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return orders_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def _reset_login_rate_limit():
|
|
|
|
|
|
from admin.routes.auth import reset_login_rate_limit_for_tests
|
|
|
|
|
|
|
|
|
|
|
|
reset_login_rate_limit_for_tests()
|
|
|
|
|
|
yield
|
|
|
|
|
|
reset_login_rate_limit_for_tests()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def app(settings):
|
|
|
|
|
|
"""FastAPI 实例(lifespan 已运行)。"""
|
|
|
|
|
|
from admin.app import create_app
|
|
|
|
|
|
|
|
|
|
|
|
return create_app(settings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def client(app):
|
|
|
|
|
|
"""httpx TestClient。"""
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
|
|
with TestClient(app) as c:
|
|
|
|
|
|
yield c
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 10:21:38 +08:00
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def route_client(app):
|
|
|
|
|
|
"""直接调用路由函数的轻量 client。"""
|
|
|
|
|
|
with RouteClient(app) as c:
|
|
|
|
|
|
yield c
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 14:49:58 +08:00
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def auth_token(client) -> str:
|
|
|
|
|
|
"""登录后返回 Bearer JWT。"""
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
|
json={"username": "admin", "password": "test-pass-123"},
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return resp.json()["access_token"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def auth_headers(auth_token) -> dict:
|
|
|
|
|
|
return {"Authorization": f"Bearer {auth_token}"}
|
2026-06-18 14:36:38 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def viewer_token(settings):
|
|
|
|
|
|
from admin.auth import encode_token
|
|
|
|
|
|
from admin.db import AdminUserRepo
|
|
|
|
|
|
|
|
|
|
|
|
repo = AdminUserRepo(settings.db_path)
|
|
|
|
|
|
viewer = repo.create("viewer", "viewer-pass-123", role="viewer")
|
|
|
|
|
|
return encode_token(viewer, settings)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def viewer_headers(viewer_token) -> dict:
|
|
|
|
|
|
return {"Authorization": f"Bearer {viewer_token}"}
|