feat(web): tighten portal privacy and cleanup flows
This commit is contained in:
@@ -47,6 +47,7 @@ class Settings:
|
|||||||
payment_notify_url: str
|
payment_notify_url: str
|
||||||
payment_return_url: str
|
payment_return_url: str
|
||||||
payment_app_id: str
|
payment_app_id: str
|
||||||
|
payment_merchant_id: str
|
||||||
payment_private_key_path: str
|
payment_private_key_path: str
|
||||||
payment_alipay_public_key_path: str
|
payment_alipay_public_key_path: str
|
||||||
smtp_host: str
|
smtp_host: str
|
||||||
@@ -59,6 +60,7 @@ class Settings:
|
|||||||
alert_recipients: list[str]
|
alert_recipients: list[str]
|
||||||
alert_webhook_urls: list[str]
|
alert_webhook_urls: list[str]
|
||||||
ops_alert_log_path: str
|
ops_alert_log_path: str
|
||||||
|
deletion_request_log_path: str
|
||||||
jwt_secret: str
|
jwt_secret: str
|
||||||
portal_token_secret: str # P2-4: 与后台 jwt_secret 分离
|
portal_token_secret: str # P2-4: 与后台 jwt_secret 分离
|
||||||
jwt_algorithm: str
|
jwt_algorithm: str
|
||||||
@@ -164,6 +166,7 @@ def load_settings() -> Settings:
|
|||||||
- GAOKAO_PAYMENT_NOTIFY_URL : 真实 provider 异步通知地址,默认空
|
- GAOKAO_PAYMENT_NOTIFY_URL : 真实 provider 异步通知地址,默认空
|
||||||
- GAOKAO_PAYMENT_RETURN_URL : 真实 provider 浏览器返回地址,默认空
|
- GAOKAO_PAYMENT_RETURN_URL : 真实 provider 浏览器返回地址,默认空
|
||||||
- GAOKAO_PAYMENT_APP_ID : 真实 provider 应用 ID,默认空
|
- GAOKAO_PAYMENT_APP_ID : 真实 provider 应用 ID,默认空
|
||||||
|
- GAOKAO_PAYMENT_MERCHANT_ID : 真实 provider 商户/卖家 ID,默认空
|
||||||
- GAOKAO_PAYMENT_PRIVATE_KEY_PATH : 真实 provider 私钥路径,默认空
|
- GAOKAO_PAYMENT_PRIVATE_KEY_PATH : 真实 provider 私钥路径,默认空
|
||||||
- GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH : 支付宝公钥路径,默认空
|
- GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH : 支付宝公钥路径,默认空
|
||||||
- GAOKAO_SMTP_HOST : SMTP 主机,默认空
|
- GAOKAO_SMTP_HOST : SMTP 主机,默认空
|
||||||
@@ -202,6 +205,7 @@ def load_settings() -> Settings:
|
|||||||
payment_notify_url=os.getenv("GAOKAO_PAYMENT_NOTIFY_URL", ""),
|
payment_notify_url=os.getenv("GAOKAO_PAYMENT_NOTIFY_URL", ""),
|
||||||
payment_return_url=os.getenv("GAOKAO_PAYMENT_RETURN_URL", ""),
|
payment_return_url=os.getenv("GAOKAO_PAYMENT_RETURN_URL", ""),
|
||||||
payment_app_id=os.getenv("GAOKAO_PAYMENT_APP_ID", ""),
|
payment_app_id=os.getenv("GAOKAO_PAYMENT_APP_ID", ""),
|
||||||
|
payment_merchant_id=os.getenv("GAOKAO_PAYMENT_MERCHANT_ID", ""),
|
||||||
payment_private_key_path=os.getenv("GAOKAO_PAYMENT_PRIVATE_KEY_PATH", ""),
|
payment_private_key_path=os.getenv("GAOKAO_PAYMENT_PRIVATE_KEY_PATH", ""),
|
||||||
payment_alipay_public_key_path=os.getenv(
|
payment_alipay_public_key_path=os.getenv(
|
||||||
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH", ""
|
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH", ""
|
||||||
@@ -216,6 +220,9 @@ def load_settings() -> Settings:
|
|||||||
alert_recipients=[s.strip() for s in os.getenv("GAOKAO_ALERT_RECIPIENTS", "").split(",") if s.strip()],
|
alert_recipients=[s.strip() for s in os.getenv("GAOKAO_ALERT_RECIPIENTS", "").split(",") if s.strip()],
|
||||||
alert_webhook_urls=[s.strip() for s in os.getenv("GAOKAO_ALERT_WEBHOOK_URLS", "").split(",") if s.strip()],
|
alert_webhook_urls=[s.strip() for s in os.getenv("GAOKAO_ALERT_WEBHOOK_URLS", "").split(",") if s.strip()],
|
||||||
ops_alert_log_path=os.getenv("GAOKAO_OPS_ALERT_LOG", "data/alerts/ops-alerts.jsonl"),
|
ops_alert_log_path=os.getenv("GAOKAO_OPS_ALERT_LOG", "data/alerts/ops-alerts.jsonl"),
|
||||||
|
deletion_request_log_path=os.getenv(
|
||||||
|
"GAOKAO_DELETION_REQUEST_LOG", "data/alerts/deletion-requests.jsonl"
|
||||||
|
),
|
||||||
jwt_secret=os.getenv("GAOKAO_JWT_SECRET", _DEV_JWT_SECRET),
|
jwt_secret=os.getenv("GAOKAO_JWT_SECRET", _DEV_JWT_SECRET),
|
||||||
portal_token_secret=_resolve_portal_token_secret(
|
portal_token_secret=_resolve_portal_token_secret(
|
||||||
os.getenv("GAOKAO_ENV", "dev")
|
os.getenv("GAOKAO_ENV", "dev")
|
||||||
|
|||||||
@@ -50,6 +50,92 @@ class OpsAlertListResponse(BaseModel):
|
|||||||
items: list[OpsAlertEventResponse]
|
items: list[OpsAlertEventResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class DeletionRequestResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
requester_name: str
|
||||||
|
requester_contact: str
|
||||||
|
reason: str
|
||||||
|
scope: str
|
||||||
|
confirm_guardian: bool
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/deletion-requests",
|
||||||
|
response_model=dict[str, Any],
|
||||||
|
summary="删除申请列表",
|
||||||
|
)
|
||||||
|
def list_deletion_requests(
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
order_id: Optional[str] = Query(None),
|
||||||
|
_: AdminUser = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings_dep),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
path = Path(settings.deletion_request_log_path)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
if path.exists():
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if order_id and str(item.get("order_id") or "") != order_id:
|
||||||
|
continue
|
||||||
|
items.append(item)
|
||||||
|
items = items[-limit:]
|
||||||
|
return {"total": len(items), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@page_router.get("/admin/deletion-requests", include_in_schema=False)
|
||||||
|
def deletion_request_admin_page(
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
order_id: Optional[str] = Query(None),
|
||||||
|
_: AdminUser = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings_dep),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
payload = list_deletion_requests(
|
||||||
|
limit=limit,
|
||||||
|
order_id=order_id,
|
||||||
|
_=_,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for item in payload["items"]:
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{escape(str(item.get('created_at') or '-'))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('order_id') or '-'))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('requester_name') or '-'))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('requester_contact') or '-'))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('scope') or '-'))}</td>"
|
||||||
|
f"<td>{escape(str(item.get('reason') or '-'))}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
rows_html = "".join(rows) or "<tr><td colspan='6'>暂无删除申请</td></tr>"
|
||||||
|
html = f"""<!doctype html>
|
||||||
|
<html lang='zh-CN'><head><meta charset='utf-8' /><title>删除申请审计</title></head>
|
||||||
|
<body style='font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f7fb;margin:0;padding:32px 20px;color:#172033;'>
|
||||||
|
<main style='max-width:1280px;margin:0 auto;display:grid;gap:16px;'>
|
||||||
|
<section style='background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;'>
|
||||||
|
<h1>删除申请审计</h1>
|
||||||
|
<p>日志路径:{escape(settings.deletion_request_log_path)}</p>
|
||||||
|
<p>总数:{payload['total']}</p>
|
||||||
|
</section>
|
||||||
|
<section style='background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;overflow:auto;'>
|
||||||
|
<table style='width:100%;border-collapse:collapse;'>
|
||||||
|
<thead><tr><th>时间</th><th>订单</th><th>申请人</th><th>联系方式</th><th>范围</th><th>原因</th></tr></thead>
|
||||||
|
<tbody>{rows_html}</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body></html>"""
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ops-alerts", response_model=OpsAlertListResponse, summary="运维告警列表")
|
@router.get("/ops-alerts", response_model=OpsAlertListResponse, summary="运维告警列表")
|
||||||
def list_ops_alerts(
|
def list_ops_alerts(
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ _STAGE_META: dict[str, tuple[str, str]] = {
|
|||||||
"processing": ("处理中", "后台已接单,正在生成审核/方案结果。"),
|
"processing": ("处理中", "后台已接单,正在生成审核/方案结果。"),
|
||||||
"report_ready": ("报告已就绪", "已可站内查看报告并下载 PDF。"),
|
"report_ready": ("报告已就绪", "已可站内查看报告并下载 PDF。"),
|
||||||
"completed": ("已完成", "订单已完成,后续可继续查看历史交付内容。"),
|
"completed": ("已完成", "订单已完成,后续可继续查看历史交付内容。"),
|
||||||
"refund_pending": ("退款申请中", "退款申请已登记,等待处理结果。"),
|
|
||||||
"refunded": ("已退款", "该订单已完成退款。"),
|
"refunded": ("已退款", "该订单已完成退款。"),
|
||||||
"payment_failed": ("支付失败", "支付未成功,请重新发起支付。"),
|
"payment_failed": ("支付失败", "支付未成功,请重新发起支付。"),
|
||||||
}
|
}
|
||||||
@@ -93,6 +92,40 @@ class PortalAttachmentUploaded(BaseModel):
|
|||||||
attachments: list[dict[str, Any]]
|
attachments: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class DeletionRequestCreate(BaseModel):
|
||||||
|
requester_name: str
|
||||||
|
requester_contact: str
|
||||||
|
reason: str
|
||||||
|
scope: str
|
||||||
|
confirm_guardian: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DeletionRequestCreated(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
request_logged: bool
|
||||||
|
next_step: str
|
||||||
|
|
||||||
|
|
||||||
|
def _log_deletion_request(
|
||||||
|
order_id: str, payload: DeletionRequestCreate, settings: Settings
|
||||||
|
) -> None:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
path = Path(settings.deletion_request_log_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
item = {
|
||||||
|
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"order_id": order_id,
|
||||||
|
"requester_name": payload.requester_name,
|
||||||
|
"requester_contact": payload.requester_contact,
|
||||||
|
"reason": payload.reason,
|
||||||
|
"scope": payload.scope,
|
||||||
|
"confirm_guardian": payload.confirm_guardian,
|
||||||
|
}
|
||||||
|
with path.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", include_in_schema=False)
|
@router.get("/", include_in_schema=False)
|
||||||
def landing_page() -> HTMLResponse:
|
def landing_page() -> HTMLResponse:
|
||||||
return HTMLResponse(_render_landing_page())
|
return HTMLResponse(_render_landing_page())
|
||||||
@@ -259,7 +292,6 @@ def _assert_portal_info_mutable(stage: str) -> None:
|
|||||||
"processing",
|
"processing",
|
||||||
"report_ready",
|
"report_ready",
|
||||||
"completed",
|
"completed",
|
||||||
"refund_pending",
|
|
||||||
"refunded",
|
"refunded",
|
||||||
}:
|
}:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -406,6 +438,29 @@ def submit_order_info(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/portal/{token}/deletion-request", include_in_schema=False)
|
||||||
|
def deletion_request_page(
|
||||||
|
token: str, settings: Settings = Depends(get_settings_dep)
|
||||||
|
) -> HTMLResponse:
|
||||||
|
order = _resolve_order_from_token(token, settings)
|
||||||
|
return HTMLResponse(_render_deletion_request_page(token, order))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/portal/{token}/deletion-request", response_model=DeletionRequestCreated)
|
||||||
|
def submit_deletion_request(
|
||||||
|
token: str,
|
||||||
|
payload: DeletionRequestCreate,
|
||||||
|
settings: Settings = Depends(get_settings_dep),
|
||||||
|
) -> DeletionRequestCreated:
|
||||||
|
order = _resolve_order_from_token(token, settings)
|
||||||
|
_log_deletion_request(order.id, payload, settings)
|
||||||
|
return DeletionRequestCreated(
|
||||||
|
order_id=order.id,
|
||||||
|
request_logged=True,
|
||||||
|
next_step="客服将在核验后处理删除申请",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/portal/{token}/notifications", include_in_schema=False)
|
@router.get("/portal/{token}/notifications", include_in_schema=False)
|
||||||
def notification_audit_page(
|
def notification_audit_page(
|
||||||
token: str, settings: Settings = Depends(get_settings_dep)
|
token: str, settings: Settings = Depends(get_settings_dep)
|
||||||
@@ -463,6 +518,7 @@ def _payment_service(settings: Settings) -> PaymentService:
|
|||||||
notify_url=settings.payment_notify_url,
|
notify_url=settings.payment_notify_url,
|
||||||
return_url=settings.payment_return_url,
|
return_url=settings.payment_return_url,
|
||||||
app_id=settings.payment_app_id,
|
app_id=settings.payment_app_id,
|
||||||
|
merchant_id=settings.payment_merchant_id,
|
||||||
private_key_path=settings.payment_private_key_path,
|
private_key_path=settings.payment_private_key_path,
|
||||||
alipay_public_key_path=settings.payment_alipay_public_key_path,
|
alipay_public_key_path=settings.payment_alipay_public_key_path,
|
||||||
)
|
)
|
||||||
@@ -508,9 +564,7 @@ def _build_portal_context(order: Order, settings: Settings) -> dict[str, Any]:
|
|||||||
report_html_ready = bool(order.audit_report and Path(order.audit_report).is_file())
|
report_html_ready = bool(order.audit_report and Path(order.audit_report).is_file())
|
||||||
report_pdf_ready = bool(order.pdf_path and Path(order.pdf_path).is_file())
|
report_pdf_ready = bool(order.pdf_path and Path(order.pdf_path).is_file())
|
||||||
report_artifacts_ready = report_html_ready and report_pdf_ready
|
report_artifacts_ready = report_html_ready and report_pdf_ready
|
||||||
if payment is not None and payment.status == "refund_pending":
|
if order.status == "refunded" or (
|
||||||
stage = "refund_pending"
|
|
||||||
elif order.status == "refunded" or (
|
|
||||||
payment is not None and payment.status == "refunded"
|
payment is not None and payment.status == "refunded"
|
||||||
):
|
):
|
||||||
stage = "refunded"
|
stage = "refunded"
|
||||||
@@ -575,25 +629,86 @@ def _build_portal_context(order: Order, settings: Settings) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_footer_links(token: str | None = None) -> str:
|
||||||
|
privacy_href = "/privacy"
|
||||||
|
terms_href = "/service-terms"
|
||||||
|
if token:
|
||||||
|
privacy_href = f"/privacy?token={escape(token)}"
|
||||||
|
terms_href = f"/service-terms?token={escape(token)}"
|
||||||
|
deletion_href = f"/portal/{escape(token)}/deletion-request"
|
||||||
|
else:
|
||||||
|
deletion_href = "/deletion-policy"
|
||||||
|
return (
|
||||||
|
f'<footer style="margin-top:24px;color:#5b6b88;font-size:14px;">'
|
||||||
|
f'<a href="{privacy_href}">隐私政策</a> · '
|
||||||
|
f'<a href="{terms_href}">服务说明与免责声明</a> · '
|
||||||
|
f'<a href="{deletion_href}">删除申请 / 数据删除说明</a>'
|
||||||
|
f"</footer>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/privacy", include_in_schema=False)
|
||||||
|
def privacy_page(token: str | None = None) -> HTMLResponse:
|
||||||
|
body = (
|
||||||
|
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' /><title>隐私政策</title></head>"
|
||||||
|
"<body style='font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#f4f7fb;padding:32px;color:#172033;'>"
|
||||||
|
"<main style='max-width:860px;margin:0 auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;'>"
|
||||||
|
"<h1>隐私政策</h1>"
|
||||||
|
"<p>我们收集下单、资料填写、支付与交付所需的最小信息,仅用于高考志愿填报服务,不用于营销出售。</p>"
|
||||||
|
"<p>如需撤回资料或申请删除,可使用“删除申请 / 数据删除说明”入口提交请求。</p>"
|
||||||
|
+ _render_footer_links(token)
|
||||||
|
+ "</main></body></html>"
|
||||||
|
)
|
||||||
|
return HTMLResponse(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/service-terms", include_in_schema=False)
|
||||||
|
def service_terms_page(token: str | None = None) -> HTMLResponse:
|
||||||
|
body = (
|
||||||
|
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' /><title>服务说明与免责声明</title></head>"
|
||||||
|
"<body style='font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#f4f7fb;padding:32px;color:#172033;'>"
|
||||||
|
"<main style='max-width:860px;margin:0 auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;'>"
|
||||||
|
"<h1>服务说明与免责声明</h1>"
|
||||||
|
"<p>本服务提供志愿填报辅助建议与报告,不承诺录取结果;监护人需知情并同意提交资料。</p>"
|
||||||
|
+ _render_footer_links(token)
|
||||||
|
+ "</main></body></html>"
|
||||||
|
)
|
||||||
|
return HTMLResponse(body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deletion-policy", include_in_schema=False)
|
||||||
|
def deletion_policy_page() -> HTMLResponse:
|
||||||
|
body = (
|
||||||
|
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' /><title>删除申请 / 数据删除说明</title></head>"
|
||||||
|
"<body style='font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;background:#f4f7fb;padding:32px;color:#172033;'>"
|
||||||
|
"<main style='max-width:860px;margin:0 auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;'>"
|
||||||
|
"<h1>删除申请 / 数据删除说明</h1>"
|
||||||
|
"<p>如需申请删除订单资料、附件或交付物,请在支付后的 Portal 中提交删除申请,客服会核验订单与监护人信息后处理。</p>"
|
||||||
|
+ _render_footer_links()
|
||||||
|
+ "</main></body></html>"
|
||||||
|
)
|
||||||
|
return HTMLResponse(body)
|
||||||
|
|
||||||
|
|
||||||
def _render_landing_page() -> str:
|
def _render_landing_page() -> str:
|
||||||
return """<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang=\"zh-CN\">
|
<html lang=\"zh-CN\">
|
||||||
<head>
|
<head>
|
||||||
<meta charset=\"utf-8\" />
|
<meta charset=\"utf-8\" />
|
||||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||||
<title>高考志愿填报智能系统 - 用户端 Web 自助服务</title>
|
<title>高考志愿填报智能系统 - 用户端 Web 自助服务</title>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background: linear-gradient(180deg,#0b1020,#121a31 65%,#f7f9fc 65%); color: #e8edf7; }
|
body {{ margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background: linear-gradient(180deg,#0b1020,#121a31 65%,#f7f9fc 65%); color: #e8edf7; }}
|
||||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 48px 20px 72px; }
|
.wrap {{ max-width: 1080px; margin: 0 auto; padding: 48px 20px 72px; }}
|
||||||
.hero { display: grid; gap: 20px; }
|
.hero {{ display: grid; gap: 20px; }}
|
||||||
h1 { margin: 0; font-size: 42px; }
|
h1 {{ margin: 0; font-size: 42px; }}
|
||||||
.sub { color: #9fb0d0; line-height: 1.7; }
|
.sub {{ color: #9fb0d0; line-height: 1.7; }}
|
||||||
.btn { display:inline-flex; padding:12px 18px; border-radius:12px; text-decoration:none; font-weight:700; }
|
.btn {{ display:inline-flex; padding:12px 18px; border-radius:12px; text-decoration:none; font-weight:700; }}
|
||||||
.btn-primary { background:#7c9cff; color:#fff; }
|
.btn-primary {{ background:#7c9cff; color:#fff; }}
|
||||||
.btn-secondary { border:1px solid rgba(159,176,208,.18); color:#fff; }
|
.btn-secondary {{ border:1px solid rgba(159,176,208,.18); color:#fff; }}
|
||||||
.grid { display:grid; gap:16px; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-top:28px; }
|
.grid {{ display:grid; gap:16px; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); margin-top:28px; }}
|
||||||
.card { background:rgba(18,26,49,.84); border:1px solid rgba(159,176,208,.18); border-radius:18px; padding:20px; }
|
.card {{ background:rgba(18,26,49,.84); border:1px solid rgba(159,176,208,.18); border-radius:18px; padding:20px; }}
|
||||||
.section { background:#f7f9fc; color:#172033; border-radius:28px; padding:28px; margin-top:32px; }
|
.section {{ background:#f7f9fc; color:#172033; border-radius:28px; padding:28px; margin-top:32px; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -620,6 +735,7 @@ def _render_landing_page() -> str:
|
|||||||
<li>站内查看报告 + PDF 交付</li>
|
<li>站内查看报告 + PDF 交付</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
{_render_footer_links()}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -627,20 +743,20 @@ def _render_landing_page() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _render_pricing_page() -> str:
|
def _render_pricing_page() -> str:
|
||||||
return """<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang=\"zh-CN\">
|
<html lang=\"zh-CN\">
|
||||||
<head>
|
<head>
|
||||||
<meta charset=\"utf-8\" />
|
<meta charset=\"utf-8\" />
|
||||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||||
<title>服务套餐 - 高考志愿填报智能系统</title>
|
<title>服务套餐 - 高考志愿填报智能系统</title>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#f4f7fb; color:#172033; }
|
body {{ margin: 0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#f4f7fb; color:#172033; }}
|
||||||
.wrap { max-width:1080px; margin:0 auto; padding:40px 20px 72px; }
|
.wrap {{ max-width:1080px; margin:0 auto; padding:40px 20px 72px; }}
|
||||||
.grid { display:grid; gap:18px; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); margin-top:28px; }
|
.grid {{ display:grid; gap:18px; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); margin-top:28px; }}
|
||||||
.card { background:#fff; border:1px solid #dbe3f0; border-radius:18px; padding:22px; box-shadow:0 10px 30px rgba(23,32,51,.06); }
|
.card {{ background:#fff; border:1px solid #dbe3f0; border-radius:18px; padding:22px; box-shadow:0 10px 30px rgba(23,32,51,.06); }}
|
||||||
.price { font-size:30px; font-weight:800; margin:10px 0; }
|
.price {{ font-size:30px; font-weight:800; margin:10px 0; }}
|
||||||
.button { display:inline-flex; margin-top:16px; padding:11px 16px; border-radius:12px; background:#1f6feb; color:#fff; text-decoration:none; font-weight:700; }
|
.button {{ display:inline-flex; margin-top:16px; padding:11px 16px; border-radius:12px; background:#1f6feb; color:#fff; text-decoration:none; font-weight:700; }}
|
||||||
.notice { margin-top:26px; padding:16px 18px; border-radius:14px; background:#fff7e6; color:#8a5a00; border:1px solid #f4d39b; }
|
.notice {{ margin-top:26px; padding:16px 18px; border-radius:14px; background:#fff7e6; color:#8a5a00; border:1px solid #f4d39b; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -665,12 +781,11 @@ def _render_pricing_page() -> str:
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
<div class=\"notice\">支付接入建设中:当前使用 Mock 支付沙箱完成本地闭环验证,后续可切换真实 provider。</div>
|
<div class=\"notice\">支付接入建设中:当前使用 Mock 支付沙箱完成本地闭环验证,后续可切换真实 provider。</div>
|
||||||
|
{_render_footer_links()}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _render_checkout_page(service_version: str) -> str:
|
def _render_checkout_page(service_version: str) -> str:
|
||||||
amount = _SERVICE_PRICES[service_version]
|
amount = _SERVICE_PRICES[service_version]
|
||||||
service_label = {
|
service_label = {
|
||||||
@@ -708,6 +823,7 @@ def _render_checkout_page(service_version: str) -> str:
|
|||||||
<button type=\"submit\">创建订单并去支付(¥{amount / 100:.0f})</button>
|
<button type=\"submit\">创建订单并去支付(¥{amount / 100:.0f})</button>
|
||||||
</form>
|
</form>
|
||||||
<p id=\"result\"></p>
|
<p id=\"result\"></p>
|
||||||
|
{_render_footer_links()}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
@@ -943,8 +1059,9 @@ def _render_info_page(
|
|||||||
<button type=\"button\" id=\"submit-step\" style=\"display:none;\" onclick=\"submitIntake('submit')\">提交资料</button>
|
<button type=\"button\" id=\"submit-step\" style=\"display:none;\" onclick=\"submitIntake('submit')\">提交资料</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></p>
|
<p><a href="/portal/{escape(token)}/status">返回订单状态页</a></p>
|
||||||
<pre id=\"result\"></pre>
|
{_render_footer_links(token)}
|
||||||
|
<pre id="result"></pre>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
let currentStep = 1;
|
let currentStep = 1;
|
||||||
@@ -1148,12 +1265,6 @@ def _render_status_page(token: str, context: dict[str, Any]) -> str:
|
|||||||
def _render_notification_audit_page(token: str, order: Order, events: list[Any]) -> str:
|
def _render_notification_audit_page(token: str, order: Order, events: list[Any]) -> str:
|
||||||
rows = []
|
rows = []
|
||||||
for event in events:
|
for event in events:
|
||||||
payload_preview = ""
|
|
||||||
try:
|
|
||||||
parsed = json.loads(event.payload_json)
|
|
||||||
payload_preview = escape(json.dumps(parsed, ensure_ascii=False, indent=2))
|
|
||||||
except Exception:
|
|
||||||
payload_preview = escape(event.payload_json or "")
|
|
||||||
failure_reason = escape(str(event.failure_reason or "-"))
|
failure_reason = escape(str(event.failure_reason or "-"))
|
||||||
rows.append(
|
rows.append(
|
||||||
"<tr>"
|
"<tr>"
|
||||||
@@ -1163,10 +1274,9 @@ def _render_notification_audit_page(token: str, order: Order, events: list[Any])
|
|||||||
f"<td>{escape(str(event.attempt_count))}</td>"
|
f"<td>{escape(str(event.attempt_count))}</td>"
|
||||||
f"<td>{escape(str(event.last_attempt_at or '-'))}</td>"
|
f"<td>{escape(str(event.last_attempt_at or '-'))}</td>"
|
||||||
f"<td>{failure_reason}</td>"
|
f"<td>{failure_reason}</td>"
|
||||||
f"<td><pre style='white-space:pre-wrap;max-width:520px'>{payload_preview}</pre></td>"
|
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
rows_html = "".join(rows) or "<tr><td colspan='7'>暂无通知事件</td></tr>"
|
rows_html = "".join(rows) or "<tr><td colspan='6'>暂无通知事件</td></tr>"
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><title>通知审计</title></head>
|
<html lang=\"zh-CN\"><head><meta charset=\"utf-8\" /><title>通知审计</title></head>
|
||||||
<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;margin:0;padding:32px 20px;color:#172033;\">
|
<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f4f7fb;margin:0;padding:32px 20px;color:#172033;\">
|
||||||
@@ -1175,13 +1285,14 @@ def _render_notification_audit_page(token: str, order: Order, events: list[Any])
|
|||||||
<h1>通知审计</h1>
|
<h1>通知审计</h1>
|
||||||
<p>订单号:{escape(order.id)}</p>
|
<p>订单号:{escape(order.id)}</p>
|
||||||
<p>服务版本:{escape(order.service_version)}</p>
|
<p>服务版本:{escape(order.service_version)}</p>
|
||||||
|
<p>仅展示通知摘要;原始 payload 与附件路径不会在前台显示。</p>
|
||||||
<p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></p>
|
<p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></p>
|
||||||
</section>
|
</section>
|
||||||
<section style=\"background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;overflow:auto;\">
|
<section style=\"background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;overflow:auto;\">
|
||||||
<table style=\"width:100%;border-collapse:collapse;\">
|
<table style=\"width:100%;border-collapse:collapse;\">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>渠道</th><th>事件</th><th>状态</th><th>尝试次数</th><th>最后尝试时间</th><th>失败原因</th><th>payload</th>
|
<th>渠道</th><th>事件</th><th>状态</th><th>尝试次数</th><th>最后尝试时间</th><th>失败原因</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{rows_html}</tbody>
|
<tbody>{rows_html}</tbody>
|
||||||
@@ -1207,3 +1318,44 @@ def _render_report_page(order: Order) -> str:
|
|||||||
content = Path(order.plan_file).read_text(encoding="utf-8")
|
content = Path(order.plan_file).read_text(encoding="utf-8")
|
||||||
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(content)}</pre></body></html>"
|
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(content)}</pre></body></html>"
|
||||||
raise HTTPException(status_code=404, detail="report not found")
|
raise HTTPException(status_code=404, detail="report not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_deletion_request_page(token: str, order: Order) -> str:
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang='zh-CN'><head><meta charset='utf-8' /><title>删除申请</title></head>
|
||||||
|
<body style='font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f7fb;padding:32px;color:#172033;'>
|
||||||
|
<main style='max-width:860px;margin:0 auto;background:#fff;border:1px solid #dbe3f0;border-radius:18px;padding:24px;'>
|
||||||
|
<h1>删除申请</h1>
|
||||||
|
<p>订单号:{escape(order.id)}</p>
|
||||||
|
<p>可用于申请删除资料、附件、交付物;客服会核验订单与监护人信息后处理。</p>
|
||||||
|
<form id='deletion-request-form'>
|
||||||
|
<label>申请人姓名<input name='requester_name' required /></label><br/>
|
||||||
|
<label>联系方式<input name='requester_contact' required /></label><br/>
|
||||||
|
<label>删除范围<input name='scope' value='order_and_attachments' required /></label><br/>
|
||||||
|
<label>申请原因<textarea name='reason' required></textarea></label><br/>
|
||||||
|
<label><input type='checkbox' name='confirm_guardian' /> 我确认监护人已知情并同意发起删除申请</label><br/>
|
||||||
|
<button type='button' onclick='submitDeletionRequest()'>提交删除申请</button>
|
||||||
|
</form>
|
||||||
|
{_render_footer_links(token)}
|
||||||
|
<pre id='result'></pre>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
async function submitDeletionRequest() {{
|
||||||
|
const form = new FormData(document.getElementById('deletion-request-form'));
|
||||||
|
const payload = {{
|
||||||
|
requester_name: form.get('requester_name') || '',
|
||||||
|
requester_contact: form.get('requester_contact') || '',
|
||||||
|
scope: form.get('scope') || '',
|
||||||
|
reason: form.get('reason') || '',
|
||||||
|
confirm_guardian: form.get('confirm_guardian') === 'on',
|
||||||
|
}};
|
||||||
|
const resp = await fetch('/portal/{escape(token)}/deletion-request', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{ 'Content-Type': 'application/json' }},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}});
|
||||||
|
const body = await resp.json();
|
||||||
|
document.getElementById('result').textContent = JSON.stringify(body, null, 2);
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body></html>"""
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def settings(tmp_path, secure_secret, monkeypatch):
|
|||||||
share_report_dir = str(tmp_path / "share_reports")
|
share_report_dir = str(tmp_path / "share_reports")
|
||||||
portal_upload_dir = str(tmp_path / "portal_uploads")
|
portal_upload_dir = str(tmp_path / "portal_uploads")
|
||||||
ops_alert_log = str(tmp_path / "ops-alerts.jsonl")
|
ops_alert_log = str(tmp_path / "ops-alerts.jsonl")
|
||||||
|
deletion_request_log = str(tmp_path / "deletion-requests.jsonl")
|
||||||
monkeypatch.setenv("GAOKAO_ENV", "dev")
|
monkeypatch.setenv("GAOKAO_ENV", "dev")
|
||||||
monkeypatch.setenv("GAOKAO_DB_PATH", db_path)
|
monkeypatch.setenv("GAOKAO_DB_PATH", db_path)
|
||||||
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path)
|
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path)
|
||||||
@@ -54,8 +55,9 @@ def settings(tmp_path, secure_secret, monkeypatch):
|
|||||||
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
|
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
|
||||||
monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret)
|
monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret)
|
||||||
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
||||||
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
|
|
||||||
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
||||||
|
monkeypatch.setenv("GAOKAO_OPS_ALERT_LOG", ops_alert_log)
|
||||||
|
monkeypatch.setenv("GAOKAO_DELETION_REQUEST_LOG", deletion_request_log)
|
||||||
|
|
||||||
from admin.config import load_settings
|
from admin.config import load_settings
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,37 @@ def test_notification_audit_page_shows_station_and_email_events(client, settings
|
|||||||
assert "report_ready" in page.text
|
assert "report_ready" in page.text
|
||||||
assert "report_ready_email" in page.text
|
assert "report_ready_email" in page.text
|
||||||
|
|
||||||
|
def test_notification_audit_page_hides_payload_details(client, settings, tmp_path: Path):
|
||||||
|
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-NOTIFY-HIDE")
|
||||||
|
_mark_paid(settings, order)
|
||||||
|
IntakeStore.for_db(settings.orders_db_path).save(
|
||||||
|
order_id=order.id,
|
||||||
|
payload={"candidate_score": 578},
|
||||||
|
submit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
report_path = tmp_path / "notify-hide-report.html"
|
||||||
|
pdf_path = tmp_path / "notify-hide-report.pdf"
|
||||||
|
report_path.write_text("<h1>report</h1>", encoding="utf-8")
|
||||||
|
pdf_path.write_bytes(b"%PDF-1.4\nnotify-hide\n")
|
||||||
|
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||||
|
dao.update(
|
||||||
|
order.id,
|
||||||
|
{"audit_report": str(report_path), "pdf_path": str(pdf_path)},
|
||||||
|
actor="test",
|
||||||
|
reason="attach_report",
|
||||||
|
)
|
||||||
|
dao.transition_status(order.id, "serving", actor="test", reason="processing")
|
||||||
|
dao.transition_status(order.id, "delivered", actor="test", reason="report_ready")
|
||||||
|
|
||||||
|
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||||
|
page = client.get(f"/portal/{token}/notifications")
|
||||||
|
assert page.status_code == 200, page.text
|
||||||
|
assert "parent@example.com" not in page.text
|
||||||
|
assert str(report_path) not in page.text
|
||||||
|
assert str(pdf_path) not in page.text
|
||||||
|
assert "report_ready_email" in page.text
|
||||||
|
|
||||||
|
|
||||||
def test_status_page_links_to_notification_audit(client, settings):
|
def test_status_page_links_to_notification_audit(client, settings):
|
||||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-NOTIFY-LINK")
|
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-NOTIFY-LINK")
|
||||||
|
|||||||
@@ -64,17 +64,37 @@ def _prepare_order_with_artifacts(
|
|||||||
)
|
)
|
||||||
return report_path, pdf_path
|
return report_path, pdf_path
|
||||||
|
|
||||||
|
def _prepare_portal_attachment(settings, order_id: str) -> Path:
|
||||||
|
upload_dir = Path(settings.portal_upload_dir) / order_id
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
attachment = upload_dir / "score-sheet.pdf"
|
||||||
|
attachment.write_bytes(b"%PDF-1.4\nportal-upload\n")
|
||||||
|
IntakeStore.for_db(settings.orders_db_path).save(
|
||||||
|
order_id=order_id,
|
||||||
|
payload={
|
||||||
|
"candidate_score": 578,
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"original_name": "score-sheet.pdf",
|
||||||
|
"stored_name": "score-sheet.pdf",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"size_bytes": attachment.stat().st_size,
|
||||||
|
"storage_path": str(attachment),
|
||||||
|
"kind": "portal_attachment",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
submit=True,
|
||||||
|
)
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
def test_admin_delete_order_removes_artifacts_and_related_records(
|
def test_admin_delete_order_removes_artifacts_and_related_records(
|
||||||
client, auth_headers, settings, tmp_path
|
client, auth_headers, settings, tmp_path
|
||||||
):
|
):
|
||||||
order = _seed_order(settings.orders_db_path)
|
order = _seed_order(settings.orders_db_path)
|
||||||
_mark_paid(settings, order)
|
_mark_paid(settings, order)
|
||||||
IntakeStore.for_db(settings.orders_db_path).save(
|
attachment = _prepare_portal_attachment(settings, order.id)
|
||||||
order_id=order.id,
|
|
||||||
payload={"candidate_score": 578, "guardian_notes": "to delete"},
|
|
||||||
submit=True,
|
|
||||||
)
|
|
||||||
report_path, pdf_path = _prepare_order_with_artifacts(settings, tmp_path, order.id)
|
report_path, pdf_path = _prepare_order_with_artifacts(settings, tmp_path, order.id)
|
||||||
|
|
||||||
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
|
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
|
||||||
@@ -92,10 +112,12 @@ def test_admin_delete_order_removes_artifacts_and_related_records(
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["action"] == "deleted"
|
assert body["action"] == "deleted"
|
||||||
assert body["order_id"] == order.id
|
assert body["order_id"] == order.id
|
||||||
assert body["files_deleted"] == 2
|
assert body["files_deleted"] == 3
|
||||||
|
|
||||||
assert not report_path.exists()
|
assert not report_path.exists()
|
||||||
assert not pdf_path.exists()
|
assert not pdf_path.exists()
|
||||||
|
assert not attachment.exists()
|
||||||
|
assert not attachment.parent.exists()
|
||||||
|
|
||||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||||
try:
|
try:
|
||||||
@@ -170,3 +192,31 @@ def test_admin_anonymize_order_masks_pii_but_keeps_order(
|
|||||||
assert deletion_service.audit_count(order.id) == 1
|
assert deletion_service.audit_count(order.id) == 1
|
||||||
finally:
|
finally:
|
||||||
deletion_service.close()
|
deletion_service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_anonymize_order_removes_portal_attachments(
|
||||||
|
client, auth_headers, settings
|
||||||
|
):
|
||||||
|
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-ANON-FILE")
|
||||||
|
_mark_paid(settings, order)
|
||||||
|
attachment = _prepare_portal_attachment(settings, order.id)
|
||||||
|
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/orders/{order.id}?mode=anonymize&reason=retention_expired",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["action"] == "anonymized"
|
||||||
|
assert body["files_deleted"] == 1
|
||||||
|
|
||||||
|
assert not attachment.exists()
|
||||||
|
assert not attachment.parent.exists()
|
||||||
|
|
||||||
|
intake_store = IntakeStore.for_db(settings.orders_db_path)
|
||||||
|
try:
|
||||||
|
intake = intake_store.get(order.id)
|
||||||
|
finally:
|
||||||
|
intake_store.close()
|
||||||
|
assert intake is not None
|
||||||
|
assert intake.payload == {}
|
||||||
|
|||||||
@@ -200,4 +200,49 @@ def test_submit_requires_at_least_one_target_preference(client, settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
assert "至少填写一个偏好与目标字段" in resp.text
|
assert "至少填写一个偏好与目标字段" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_info_page_exposes_policy_and_deletion_links(client, settings):
|
||||||
|
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-INFO-LINKS")
|
||||||
|
_mark_paid(settings, order)
|
||||||
|
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||||
|
|
||||||
|
page = client.get(f"/portal/{token}/info")
|
||||||
|
assert page.status_code == 200, page.text
|
||||||
|
assert f'/privacy?token={token}' in page.text
|
||||||
|
assert f'/service-terms?token={token}' in page.text
|
||||||
|
assert f'/portal/{token}/deletion-request' in page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_portal_deletion_request_is_logged_and_visible_in_admin(client, auth_headers, settings):
|
||||||
|
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-DELETE-REQ")
|
||||||
|
_mark_paid(settings, order)
|
||||||
|
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||||
|
|
||||||
|
form_page = client.get(f"/portal/{token}/deletion-request")
|
||||||
|
assert form_page.status_code == 200, form_page.text
|
||||||
|
assert "删除申请" in form_page.text
|
||||||
|
|
||||||
|
submit = client.post(
|
||||||
|
f"/portal/{token}/deletion-request",
|
||||||
|
json={
|
||||||
|
"requester_name": "张家长",
|
||||||
|
"requester_contact": "parent@example.com",
|
||||||
|
"reason": "需要撤回资料并删除附件",
|
||||||
|
"scope": "order_and_attachments",
|
||||||
|
"confirm_guardian": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert submit.status_code == 200, submit.text
|
||||||
|
body = submit.json()
|
||||||
|
assert body["order_id"] == order.id
|
||||||
|
assert body["request_logged"] is True
|
||||||
|
|
||||||
|
admin_page = client.get(
|
||||||
|
f"/admin/deletion-requests?order_id={order.id}", headers=auth_headers
|
||||||
|
)
|
||||||
|
assert admin_page.status_code == 200, admin_page.text
|
||||||
|
assert order.id in admin_page.text
|
||||||
|
assert "需要撤回资料并删除附件" in admin_page.text
|
||||||
|
assert "parent@example.com" in admin_page.text
|
||||||
@@ -57,6 +57,7 @@ def test_alipay_notify_marks_order_paid_and_is_idempotent(tmp_path, monkeypatch)
|
|||||||
"https://example.com/portal/payment-return",
|
"https://example.com/portal/payment-return",
|
||||||
)
|
)
|
||||||
monkeypatch.setenv("GAOKAO_PAYMENT_APP_ID", "20260001")
|
monkeypatch.setenv("GAOKAO_PAYMENT_APP_ID", "20260001")
|
||||||
|
monkeypatch.setenv("GAOKAO_PAYMENT_MERCHANT_ID", "2088123412341234")
|
||||||
monkeypatch.setenv("GAOKAO_PAYMENT_PRIVATE_KEY_PATH", str(private_path))
|
monkeypatch.setenv("GAOKAO_PAYMENT_PRIVATE_KEY_PATH", str(private_path))
|
||||||
monkeypatch.setenv("GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH", str(public_path))
|
monkeypatch.setenv("GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH", str(public_path))
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ def test_alipay_notify_marks_order_paid_and_is_idempotent(tmp_path, monkeypatch)
|
|||||||
notify_url=settings.payment_notify_url,
|
notify_url=settings.payment_notify_url,
|
||||||
return_url=settings.payment_return_url,
|
return_url=settings.payment_return_url,
|
||||||
app_id=settings.payment_app_id,
|
app_id=settings.payment_app_id,
|
||||||
|
merchant_id=settings.payment_merchant_id,
|
||||||
private_key_path=settings.payment_private_key_path,
|
private_key_path=settings.payment_private_key_path,
|
||||||
alipay_public_key_path=settings.payment_alipay_public_key_path,
|
alipay_public_key_path=settings.payment_alipay_public_key_path,
|
||||||
)
|
)
|
||||||
@@ -103,6 +105,7 @@ def test_alipay_notify_marks_order_paid_and_is_idempotent(tmp_path, monkeypatch)
|
|||||||
|
|
||||||
provider = AlipayProvider(
|
provider = AlipayProvider(
|
||||||
app_id=settings.payment_app_id,
|
app_id=settings.payment_app_id,
|
||||||
|
merchant_id=settings.payment_merchant_id,
|
||||||
private_key_path=settings.payment_private_key_path,
|
private_key_path=settings.payment_private_key_path,
|
||||||
alipay_public_key_path=settings.payment_alipay_public_key_path,
|
alipay_public_key_path=settings.payment_alipay_public_key_path,
|
||||||
notify_url=settings.payment_notify_url,
|
notify_url=settings.payment_notify_url,
|
||||||
|
|||||||
@@ -142,6 +142,34 @@ def test_payment_return_redirects_to_portal_status(client):
|
|||||||
assert resp.headers["location"] == f"/portal/{token}/status"
|
assert resp.headers["location"] == f"/portal/{token}/status"
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_pages_include_privacy_and_deletion_links(client):
|
||||||
|
landing = client.get("/")
|
||||||
|
assert landing.status_code == 200, landing.text
|
||||||
|
assert 'href="/privacy"' in landing.text
|
||||||
|
assert 'href="/service-terms"' in landing.text
|
||||||
|
assert 'href="/deletion-policy"' in landing.text
|
||||||
|
|
||||||
|
pricing = client.get("/pricing")
|
||||||
|
assert pricing.status_code == 200, pricing.text
|
||||||
|
assert 'href="/privacy"' in pricing.text
|
||||||
|
assert 'href="/service-terms"' in pricing.text
|
||||||
|
assert 'href="/deletion-policy"' in pricing.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_and_deletion_pages_are_served(client):
|
||||||
|
privacy = client.get("/privacy")
|
||||||
|
assert privacy.status_code == 200, privacy.text
|
||||||
|
assert "隐私政策" in privacy.text
|
||||||
|
|
||||||
|
terms = client.get("/service-terms")
|
||||||
|
assert terms.status_code == 200, terms.text
|
||||||
|
assert "服务说明与免责声明" in terms.text
|
||||||
|
|
||||||
|
deletion = client.get("/deletion-policy")
|
||||||
|
assert deletion.status_code == 200, deletion.text
|
||||||
|
assert "删除申请" in deletion.text
|
||||||
|
|
||||||
|
|
||||||
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(
|
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(
|
||||||
tmp_path, monkeypatch
|
tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS delivery_notifications (
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
DELIVERY_EVENT_STATUSES = ("ready", "validated", "delivered", "failed", "sent")
|
DELIVERY_EVENT_STATUSES = ("ready", "validated", "delivered", "failed")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -128,27 +128,6 @@ class DeliveryNotificationService:
|
|||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
self._conn.rollback()
|
self._conn.rollback()
|
||||||
|
|
||||||
def mark_sent(
|
|
||||||
self,
|
|
||||||
order_id: str,
|
|
||||||
event_type: str = "report_ready",
|
|
||||||
*,
|
|
||||||
payload_json: str | None = None,
|
|
||||||
sent_at: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
if sent_at is None:
|
|
||||||
sent_at = utc_now_iso()
|
|
||||||
if payload_json is None:
|
|
||||||
self._conn.execute(
|
|
||||||
"UPDATE delivery_notifications SET status='sent', last_attempt_at=?, failure_reason=NULL WHERE order_id=? AND event_type=?",
|
|
||||||
(sent_at, order_id, event_type),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._conn.execute(
|
|
||||||
"UPDATE delivery_notifications SET status='sent', payload_json=?, last_attempt_at=?, failure_reason=NULL WHERE order_id=? AND event_type=?",
|
|
||||||
(payload_json, sent_at, order_id, event_type),
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
def mark_validated(
|
def mark_validated(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -52,7 +53,12 @@ class OrderDeletionService:
|
|||||||
order = dao.get(order_id)
|
order = dao.get(order_id)
|
||||||
except OrderNotFound:
|
except OrderNotFound:
|
||||||
raise
|
raise
|
||||||
files_deleted = self._delete_artifacts(order.audit_report, order.pdf_path)
|
intake_payload = self._get_intake_payload(order_id)
|
||||||
|
files_deleted = self._delete_order_files(
|
||||||
|
order_id,
|
||||||
|
intake_payload=intake_payload,
|
||||||
|
artifact_paths=(order.audit_report, order.pdf_path),
|
||||||
|
)
|
||||||
deleted = dao.delete(order_id)
|
deleted = dao.delete(order_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise OrderNotFound(f"订单不存在: {order_id}")
|
raise OrderNotFound(f"订单不存在: {order_id}")
|
||||||
@@ -76,6 +82,12 @@ class OrderDeletionService:
|
|||||||
dao.get(order_id)
|
dao.get(order_id)
|
||||||
except OrderNotFound:
|
except OrderNotFound:
|
||||||
raise
|
raise
|
||||||
|
intake_payload = self._get_intake_payload(order_id)
|
||||||
|
files_deleted = self._delete_order_files(
|
||||||
|
order_id,
|
||||||
|
intake_payload=intake_payload,
|
||||||
|
artifact_paths=(),
|
||||||
|
)
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -118,11 +130,13 @@ class OrderDeletionService:
|
|||||||
action="anonymize",
|
action="anonymize",
|
||||||
actor=actor,
|
actor=actor,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
files_deleted=0,
|
files_deleted=files_deleted,
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return DeletionResult(
|
return DeletionResult(
|
||||||
order_id=order_id, action="anonymized", files_deleted=0
|
order_id=order_id,
|
||||||
|
action="anonymized",
|
||||||
|
files_deleted=files_deleted,
|
||||||
)
|
)
|
||||||
|
|
||||||
def audit_count(self, order_id: str) -> int:
|
def audit_count(self, order_id: str) -> int:
|
||||||
@@ -146,6 +160,61 @@ class OrderDeletionService:
|
|||||||
(order_id, action, actor, reason, files_deleted, utc_now_iso()),
|
(order_id, action, actor, reason, files_deleted, utc_now_iso()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_intake_payload(self, order_id: str) -> dict[str, object]:
|
||||||
|
if not self._table_exists("order_intakes"):
|
||||||
|
return {}
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT payload_json FROM order_intakes WHERE order_id=?",
|
||||||
|
(order_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return {}
|
||||||
|
raw = row[0] or "{}"
|
||||||
|
try:
|
||||||
|
parsed = json.loads(str(raw))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return parsed if isinstance(parsed, dict) else {}
|
||||||
|
|
||||||
|
def _delete_order_files(
|
||||||
|
self,
|
||||||
|
order_id: str,
|
||||||
|
*,
|
||||||
|
intake_payload: dict[str, object],
|
||||||
|
artifact_paths: tuple[str | None, ...],
|
||||||
|
) -> int:
|
||||||
|
deleted = self._delete_artifacts(*artifact_paths)
|
||||||
|
deleted += self._delete_portal_attachments(order_id, intake_payload)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def _delete_portal_attachments(
|
||||||
|
self, order_id: str, intake_payload: dict[str, object]
|
||||||
|
) -> int:
|
||||||
|
attachments = intake_payload.get("attachments")
|
||||||
|
if not isinstance(attachments, list):
|
||||||
|
return 0
|
||||||
|
deleted = 0
|
||||||
|
parent_dirs: set[Path] = set()
|
||||||
|
for item in attachments:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
raw_path = item.get("storage_path")
|
||||||
|
if not raw_path:
|
||||||
|
continue
|
||||||
|
path = Path(str(raw_path))
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
deleted += 1
|
||||||
|
parent_dirs.add(path.parent)
|
||||||
|
order_dir = None
|
||||||
|
if parent_dirs:
|
||||||
|
order_dir = sorted(parent_dirs, key=lambda p: len(p.parts))[0]
|
||||||
|
else:
|
||||||
|
order_dir = Path("data/portal_uploads") / order_id
|
||||||
|
if order_dir.exists() and order_dir.is_dir() and not any(order_dir.iterdir()):
|
||||||
|
order_dir.rmdir()
|
||||||
|
return deleted
|
||||||
|
|
||||||
def _table_exists(self, table_name: str) -> bool:
|
def _table_exists(self, table_name: str) -> bool:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS payments (
|
|||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0),
|
amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0),
|
||||||
currency TEXT NOT NULL,
|
currency TEXT NOT NULL,
|
||||||
status TEXT NOT NULL CHECK(status IN ('pending','paid','failed','refund_pending','refunded')),
|
status TEXT NOT NULL CHECK(status IN ('pending','paid','failed','refunded')),
|
||||||
provider_trade_no TEXT,
|
provider_trade_no TEXT,
|
||||||
checkout_token TEXT,
|
checkout_token TEXT,
|
||||||
callback_payload TEXT,
|
callback_payload TEXT,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ _PROVIDER_REQUIREMENTS: dict[str, list[str]] = {
|
|||||||
"alipay_sim": [],
|
"alipay_sim": [],
|
||||||
"alipay": [
|
"alipay": [
|
||||||
"GAOKAO_PAYMENT_APP_ID",
|
"GAOKAO_PAYMENT_APP_ID",
|
||||||
|
"GAOKAO_PAYMENT_MERCHANT_ID",
|
||||||
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH",
|
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH",
|
||||||
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH",
|
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH",
|
||||||
"GAOKAO_PAYMENT_NOTIFY_URL",
|
"GAOKAO_PAYMENT_NOTIFY_URL",
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ class AlipayProvider:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
app_id: str,
|
app_id: str,
|
||||||
|
merchant_id: str,
|
||||||
private_key_path: str,
|
private_key_path: str,
|
||||||
alipay_public_key_path: str,
|
alipay_public_key_path: str,
|
||||||
notify_url: str,
|
notify_url: str,
|
||||||
return_url: str,
|
return_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.app_id = app_id.strip()
|
self.app_id = app_id.strip()
|
||||||
|
self.merchant_id = merchant_id.strip()
|
||||||
self.notify_url = notify_url.strip()
|
self.notify_url = notify_url.strip()
|
||||||
self.return_url = return_url.strip()
|
self.return_url = return_url.strip()
|
||||||
self.private_key_path = Path(private_key_path)
|
self.private_key_path = Path(private_key_path)
|
||||||
@@ -88,6 +90,7 @@ class AlipayProvider:
|
|||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
payload = {
|
payload = {
|
||||||
"app_id": self.app_id,
|
"app_id": self.app_id,
|
||||||
|
"seller_id": self.merchant_id,
|
||||||
"notify_id": f"notify_{payment_id}",
|
"notify_id": f"notify_{payment_id}",
|
||||||
"out_trade_no": payment_id,
|
"out_trade_no": payment_id,
|
||||||
"trade_no": provider_trade_no,
|
"trade_no": provider_trade_no,
|
||||||
@@ -111,6 +114,7 @@ class AlipayProvider:
|
|||||||
"status": str(payload.get("trade_status") or "TRADE_SUCCESS"),
|
"status": str(payload.get("trade_status") or "TRADE_SUCCESS"),
|
||||||
"app_id": str(payload.get("app_id") or ""),
|
"app_id": str(payload.get("app_id") or ""),
|
||||||
"notify_id": str(payload.get("notify_id") or ""),
|
"notify_id": str(payload.get("notify_id") or ""),
|
||||||
|
"merchant_id": str(payload.get("seller_id") or payload.get("merchant_id") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def sign_payload(self, payload: dict[str, Any]) -> str:
|
def sign_payload(self, payload: dict[str, Any]) -> str:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def _build_provider(
|
|||||||
notify_url: str = "",
|
notify_url: str = "",
|
||||||
return_url: str = "",
|
return_url: str = "",
|
||||||
app_id: str = "",
|
app_id: str = "",
|
||||||
|
merchant_id: str = "",
|
||||||
private_key_path: str = "",
|
private_key_path: str = "",
|
||||||
alipay_public_key_path: str = "",
|
alipay_public_key_path: str = "",
|
||||||
):
|
):
|
||||||
@@ -43,6 +44,7 @@ def _build_provider(
|
|||||||
"alipay",
|
"alipay",
|
||||||
env={
|
env={
|
||||||
"GAOKAO_PAYMENT_APP_ID": app_id,
|
"GAOKAO_PAYMENT_APP_ID": app_id,
|
||||||
|
"GAOKAO_PAYMENT_MERCHANT_ID": merchant_id,
|
||||||
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH": private_key_path,
|
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH": private_key_path,
|
||||||
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH": alipay_public_key_path,
|
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH": alipay_public_key_path,
|
||||||
"GAOKAO_PAYMENT_NOTIFY_URL": notify_url,
|
"GAOKAO_PAYMENT_NOTIFY_URL": notify_url,
|
||||||
@@ -61,6 +63,7 @@ def _build_provider(
|
|||||||
raise PaymentError("alipay provider not ready: " + "; ".join(details))
|
raise PaymentError("alipay provider not ready: " + "; ".join(details))
|
||||||
return AlipayProvider(
|
return AlipayProvider(
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
|
merchant_id=merchant_id,
|
||||||
private_key_path=private_key_path,
|
private_key_path=private_key_path,
|
||||||
alipay_public_key_path=alipay_public_key_path,
|
alipay_public_key_path=alipay_public_key_path,
|
||||||
notify_url=notify_url,
|
notify_url=notify_url,
|
||||||
@@ -85,6 +88,7 @@ class PaymentService:
|
|||||||
notify_url: str = "",
|
notify_url: str = "",
|
||||||
return_url: str = "",
|
return_url: str = "",
|
||||||
app_id: str = "",
|
app_id: str = "",
|
||||||
|
merchant_id: str = "",
|
||||||
private_key_path: str = "",
|
private_key_path: str = "",
|
||||||
alipay_public_key_path: str = "",
|
alipay_public_key_path: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -97,6 +101,7 @@ class PaymentService:
|
|||||||
notify_url=notify_url,
|
notify_url=notify_url,
|
||||||
return_url=return_url,
|
return_url=return_url,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
|
merchant_id=merchant_id,
|
||||||
private_key_path=private_key_path,
|
private_key_path=private_key_path,
|
||||||
alipay_public_key_path=alipay_public_key_path,
|
alipay_public_key_path=alipay_public_key_path,
|
||||||
)
|
)
|
||||||
@@ -112,6 +117,7 @@ class PaymentService:
|
|||||||
notify_url: str = "",
|
notify_url: str = "",
|
||||||
return_url: str = "",
|
return_url: str = "",
|
||||||
app_id: str = "",
|
app_id: str = "",
|
||||||
|
merchant_id: str = "",
|
||||||
private_key_path: str = "",
|
private_key_path: str = "",
|
||||||
alipay_public_key_path: str = "",
|
alipay_public_key_path: str = "",
|
||||||
) -> "PaymentService":
|
) -> "PaymentService":
|
||||||
@@ -123,6 +129,7 @@ class PaymentService:
|
|||||||
notify_url=notify_url,
|
notify_url=notify_url,
|
||||||
return_url=return_url,
|
return_url=return_url,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
|
merchant_id=merchant_id,
|
||||||
private_key_path=private_key_path,
|
private_key_path=private_key_path,
|
||||||
alipay_public_key_path=alipay_public_key_path,
|
alipay_public_key_path=alipay_public_key_path,
|
||||||
)
|
)
|
||||||
@@ -136,7 +143,7 @@ class PaymentService:
|
|||||||
if existing is not None and existing.status in {
|
if existing is not None and existing.status in {
|
||||||
"pending",
|
"pending",
|
||||||
"paid",
|
"paid",
|
||||||
"refund_pending",
|
"refunded",
|
||||||
}:
|
}:
|
||||||
return PaymentCheckout(
|
return PaymentCheckout(
|
||||||
payment_id=existing.id,
|
payment_id=existing.id,
|
||||||
@@ -198,16 +205,33 @@ class PaymentService:
|
|||||||
if normalized_status not in success_statuses:
|
if normalized_status not in success_statuses:
|
||||||
raise PaymentError("payment status not successful")
|
raise PaymentError("payment status not successful")
|
||||||
expected_app_id = str(getattr(self.provider, "app_id", "") or "").strip()
|
expected_app_id = str(getattr(self.provider, "app_id", "") or "").strip()
|
||||||
|
expected_merchant_id = str(
|
||||||
|
getattr(self.provider, "merchant_id", "") or ""
|
||||||
|
).strip()
|
||||||
received_app_id = str(
|
received_app_id = str(
|
||||||
normalized_payload.get("app_id") or payload.get("app_id") or ""
|
normalized_payload.get("app_id") or payload.get("app_id") or ""
|
||||||
).strip()
|
).strip()
|
||||||
received_notify_id = str(
|
received_notify_id = str(
|
||||||
normalized_payload.get("notify_id") or payload.get("notify_id") or ""
|
normalized_payload.get("notify_id") or payload.get("notify_id") or ""
|
||||||
).strip()
|
).strip()
|
||||||
|
received_merchant_id = str(
|
||||||
|
normalized_payload.get("merchant_id")
|
||||||
|
or payload.get("seller_id")
|
||||||
|
or payload.get("merchant_id")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
if expected_app_id and not received_notify_id:
|
if expected_app_id and not received_notify_id:
|
||||||
raise PaymentError("payment notify_id missing")
|
raise PaymentError("payment notify_id missing")
|
||||||
if expected_app_id and received_app_id and expected_app_id != received_app_id:
|
if expected_app_id and received_app_id and expected_app_id != received_app_id:
|
||||||
raise PaymentError("payment app_id mismatch")
|
raise PaymentError("payment app_id mismatch")
|
||||||
|
if expected_merchant_id and not received_merchant_id:
|
||||||
|
raise PaymentError("payment merchant_id missing")
|
||||||
|
if (
|
||||||
|
expected_merchant_id
|
||||||
|
and received_merchant_id
|
||||||
|
and expected_merchant_id != received_merchant_id
|
||||||
|
):
|
||||||
|
raise PaymentError("payment merchant_id mismatch")
|
||||||
provider_trade_no = str(
|
provider_trade_no = str(
|
||||||
normalized_payload.get("provider_trade_no") or ""
|
normalized_payload.get("provider_trade_no") or ""
|
||||||
).strip()
|
).strip()
|
||||||
@@ -269,19 +293,6 @@ class PaymentService:
|
|||||||
return RefundRequestResult(
|
return RefundRequestResult(
|
||||||
payment_id=payment.id, status=payment.status
|
payment_id=payment.id, status=payment.status
|
||||||
)
|
)
|
||||||
if payment.status == "refund_pending":
|
|
||||||
# Idempotent refund request: keep order advanced to refunded
|
|
||||||
# to keep portal / analytics in a single coherent terminal state.
|
|
||||||
if orders_dao.get(order_id).status != "refunded":
|
|
||||||
orders_dao.transition_status(
|
|
||||||
order_id,
|
|
||||||
"refunded",
|
|
||||||
actor="refund_request_idempotent",
|
|
||||||
reason=reason,
|
|
||||||
)
|
|
||||||
return RefundRequestResult(
|
|
||||||
payment_id=payment.id, status=payment.status
|
|
||||||
)
|
|
||||||
if payment.status != "paid":
|
if payment.status != "paid":
|
||||||
raise PaymentError("payment is not refundable")
|
raise PaymentError("payment is not refundable")
|
||||||
updated = payments.update_status(
|
updated = payments.update_status(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def _provider(tmp_path: Path) -> AlipayProvider:
|
|||||||
private_path, public_path = _write_keypair(tmp_path)
|
private_path, public_path = _write_keypair(tmp_path)
|
||||||
return AlipayProvider(
|
return AlipayProvider(
|
||||||
app_id="20260001",
|
app_id="20260001",
|
||||||
|
merchant_id="2088123412341234",
|
||||||
private_key_path=str(private_path),
|
private_key_path=str(private_path),
|
||||||
alipay_public_key_path=str(public_path),
|
alipay_public_key_path=str(public_path),
|
||||||
notify_url="https://example.com/api/public/payments/alipay/notify",
|
notify_url="https://example.com/api/public/payments/alipay/notify",
|
||||||
@@ -92,4 +93,18 @@ def test_alipay_provider_verifies_signed_webhook_payload(tmp_path):
|
|||||||
"status": "TRADE_SUCCESS",
|
"status": "TRADE_SUCCESS",
|
||||||
"app_id": "20260001",
|
"app_id": "20260001",
|
||||||
"notify_id": "notify_pay_123",
|
"notify_id": "notify_pay_123",
|
||||||
|
"merchant_id": "2088123412341234",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_alipay_provider_normalizes_seller_id(tmp_path):
|
||||||
|
provider = _provider(tmp_path)
|
||||||
|
payload, _signature = provider.build_webhook_request(
|
||||||
|
payment_id="pay_merchant",
|
||||||
|
amount_cents=9900,
|
||||||
|
provider_trade_no="ALI-TRADE-MERCHANT",
|
||||||
|
)
|
||||||
|
payload["seller_id"] = "2088123412341234"
|
||||||
|
|
||||||
|
normalized = provider.normalize_webhook_payload(payload)
|
||||||
|
assert normalized["merchant_id"] == "2088123412341234"
|
||||||
|
|||||||
@@ -23,7 +23,23 @@ def test_alipay_provider_report_detects_missing_credentials():
|
|||||||
assert report.ready is False
|
assert report.ready is False
|
||||||
assert "GAOKAO_PAYMENT_APP_ID" in report.missing_env_vars
|
assert "GAOKAO_PAYMENT_APP_ID" in report.missing_env_vars
|
||||||
assert "GAOKAO_PAYMENT_PRIVATE_KEY_PATH" in report.missing_env_vars
|
assert "GAOKAO_PAYMENT_PRIVATE_KEY_PATH" in report.missing_env_vars
|
||||||
|
assert "GAOKAO_PAYMENT_MERCHANT_ID" in report.missing_env_vars
|
||||||
|
|
||||||
|
def test_alipay_provider_report_requires_merchant_binding(tmp_path):
|
||||||
|
private_key = tmp_path / "private.pem"
|
||||||
|
public_key = tmp_path / "alipay_public.pem"
|
||||||
|
private_key.write_text("private", encoding="utf-8")
|
||||||
|
env = {
|
||||||
|
"GAOKAO_PAYMENT_APP_ID": "20260001",
|
||||||
|
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH": str(private_key),
|
||||||
|
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH": str(public_key),
|
||||||
|
"GAOKAO_PAYMENT_NOTIFY_URL": "https://example.com/notify",
|
||||||
|
"GAOKAO_PAYMENT_RETURN_URL": "https://example.com/return",
|
||||||
|
"GAOKAO_PAYMENT_WEBHOOK_SECRET": "secret",
|
||||||
|
}
|
||||||
|
report = build_provider_readiness_report("alipay", env=env)
|
||||||
|
assert report.ready is False
|
||||||
|
assert "GAOKAO_PAYMENT_MERCHANT_ID" in report.missing_env_vars
|
||||||
|
|
||||||
def test_unsupported_provider_note_is_explicit():
|
def test_unsupported_provider_note_is_explicit():
|
||||||
report = build_provider_readiness_report("unknown-provider", env={})
|
report = build_provider_readiness_report("unknown-provider", env={})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _mark_paid(settings, order: Order) -> PaymentService:
|
|||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
def test_refund_request_marks_portal_as_refund_pending(client, settings):
|
def test_refund_request_marks_portal_as_refunded(client, settings):
|
||||||
order = _seed_order(settings.orders_db_path)
|
order = _seed_order(settings.orders_db_path)
|
||||||
service = _mark_paid(settings, order)
|
service = _mark_paid(settings, order)
|
||||||
|
|
||||||
@@ -58,3 +58,10 @@ def test_refund_request_marks_portal_as_refund_pending(client, settings):
|
|||||||
status_page = client.get(f"/portal/{token}/status")
|
status_page = client.get(f"/portal/{token}/status")
|
||||||
assert status_page.status_code == 200, status_page.text
|
assert status_page.status_code == 200, status_page.text
|
||||||
assert "已退款" in status_page.text
|
assert "已退款" in status_page.text
|
||||||
|
assert "退款申请中" not in status_page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_refund_pending_legacy_status_is_removed():
|
||||||
|
from data.payments import dao as payment_dao
|
||||||
|
|
||||||
|
assert "refund_pending" not in payment_dao.SCHEMA_SQL
|
||||||
|
|||||||
@@ -163,3 +163,33 @@ def test_handle_webhook_rejects_missing_notify_id_for_bound_provider(settings):
|
|||||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||||
unchanged = dao.get(order.id)
|
unchanged = dao.get(order.id)
|
||||||
assert unchanged.status == "pending"
|
assert unchanged.status == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_webhook_rejects_merchant_id_mismatch(settings):
|
||||||
|
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-WEBHOOK-MERCHANT")
|
||||||
|
service = PaymentService.for_db(
|
||||||
|
settings.orders_db_path,
|
||||||
|
base_url=settings.payment_base_url,
|
||||||
|
webhook_secret=settings.payment_webhook_secret,
|
||||||
|
)
|
||||||
|
setattr(service.provider, "app_id", "expected-app-id")
|
||||||
|
setattr(service.provider, "merchant_id", "expected-merchant")
|
||||||
|
checkout = service.create_checkout(order.id, portal_token="portal-token")
|
||||||
|
payload, headers = service.provider.build_webhook_request(
|
||||||
|
payment_id=checkout.payment_id,
|
||||||
|
amount_cents=order.amount_cents,
|
||||||
|
provider_trade_no="MOCK-MERCHANT-001",
|
||||||
|
)
|
||||||
|
payload = cast(dict[str, Any], payload)
|
||||||
|
headers = cast(dict[str, str], headers)
|
||||||
|
payload["app_id"] = "expected-app-id"
|
||||||
|
payload["notify_id"] = "notify-merchant-001"
|
||||||
|
payload["seller_id"] = "wrong-merchant"
|
||||||
|
headers["X-Mock-Signature"] = service.provider.sign_payload(payload)
|
||||||
|
|
||||||
|
with pytest.raises(PaymentError, match="payment merchant_id mismatch"):
|
||||||
|
service.handle_webhook(payload, headers["X-Mock-Signature"])
|
||||||
|
|
||||||
|
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||||
|
unchanged = dao.get(order.id)
|
||||||
|
assert unchanged.status == "pending"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# 开发 / 本地 smoke 模板;不是生产部署闭环定义。
|
||||||
|
# 生产上线以 docs/PRODUCTION_DEPLOYMENT_CHECKLIST_2026-06-15.md 和目标主机 systemd/cron/env 注入为准。
|
||||||
services:
|
services:
|
||||||
gaokao-admin:
|
gaokao-admin:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ bash scripts/backup_verify.sh
|
|||||||
|
|
||||||
- 异机 / 异地备份尚未落地
|
- 异机 / 异地备份尚未落地
|
||||||
- 目标主机上的 cron / systemd timer 尚未实际安装验收
|
- 目标主机上的 cron / systemd timer 尚未实际安装验收
|
||||||
- 备份失败告警链(邮件 / IM / 监控)未接入
|
- 真实 SMTP / IM / 监控通道的目标机联调与失败演练记录仍未归档
|
||||||
- 密钥轮换与应急恢复仍缺真实执行记录
|
- 密钥轮换与应急恢复仍缺真实执行记录
|
||||||
|
|
||||||
## 10. 不可降级的不变量
|
## 10. 不可降级的不变量
|
||||||
@@ -194,6 +194,7 @@ bash scripts/backup_verify.sh
|
|||||||
- 接入 P1-8 整改的 service-level smoke (已完成)
|
- 接入 P1-8 整改的 service-level smoke (已完成)
|
||||||
- 增加异机异地备份 (P2-6 之后)
|
- 增加异机异地备份 (P2-6 之后)
|
||||||
- 接入告警推送 (P1-8 后续)
|
- 接入告警推送 (P1-8 后续)
|
||||||
|
- 备份失败告警链仍需目标主机接入真实 SMTP / IM / monitoring;仓库内尚未提供可直接安装的 backup alert service
|
||||||
- 接入密钥轮换演练 (P2-6 之后)
|
- 接入密钥轮换演练 (P2-6 之后)
|
||||||
- 真实演练记录归档 (例: `reports/DR_DRILL_2026-07-01.md`)
|
- 真实演练记录归档(模板:`reports/DR_DRILL_TEMPLATE.md`,示例:`reports/DR_DRILL_2026-07-01.md`)
|
||||||
- 当前 restore smoke 证明的是“最小服务链可用”,不是完整生产级全链恢复
|
- 当前 restore smoke 证明的是“最小服务链可用”,不是完整生产级全链恢复
|
||||||
|
|||||||
@@ -51,10 +51,9 @@
|
|||||||
- core = 100.00%
|
- core = 100.00%
|
||||||
- 满足:整体≥80%、核心≥100%
|
- 满足:整体≥80%、核心≥100%
|
||||||
|
|
||||||
最新覆盖率口径已统一:
|
- 本地与 CI 的硬门禁统一收敛到 `scripts/check_coverage_gate.py`
|
||||||
|
- `dev-verify.sh` 与 GitHub CI 都执行同一组硬阈值检查
|
||||||
- 单一真相源:`scripts/check_coverage_gate.py`
|
- `codecov.yml` 当前保持同值展示口径,但不是执行单源,也不是阻断门禁
|
||||||
- CI / dev-verify / codecov 全部指向同一阈值
|
|
||||||
|
|
||||||
三仓同步状态:
|
三仓同步状态:
|
||||||
|
|
||||||
@@ -137,19 +136,17 @@
|
|||||||
|
|
||||||
完整清单见 `docs/P0_P1_P2_REMEDIATION_PLAN_2026-06-14.md`:
|
完整清单见 `docs/P0_P1_P2_REMEDIATION_PLAN_2026-06-14.md`:
|
||||||
|
|
||||||
- P2-1 公共下单孤儿订单
|
|
||||||
- P2-3 delivery `sent` 语义修正
|
|
||||||
- P1-3 真实支付回调校验
|
- P1-3 真实支付回调校验
|
||||||
- P1-4 webhook DB 连接污染
|
- P1-8 备份恢复演练 / 目标机告警接入
|
||||||
- P1-6 分享 allowlist
|
- 隐私政策 / 服务协议 / 删除工单正式入口
|
||||||
- P1-8 备份恢复演练
|
|
||||||
- P2-4 portal token / JWT secret 分离(已通过 P2-4 单元测试 + prod fail-closed)
|
|
||||||
- P2-5 payment webhook secret fail-closed(已通过 P2-5 单元测试 + prod fail-closed)
|
|
||||||
|
|
||||||
已完成并不再列为整改项:
|
已完成并不再列为整改项:
|
||||||
|
|
||||||
|
- P2-1 公共下单孤儿订单
|
||||||
- P2-2 channel_sync 单一 DAO 真相
|
- P2-2 channel_sync 单一 DAO 真相
|
||||||
- P2-6 历史快照头注补齐
|
- P2-3 delivery 通知状态语义收口(`sent` 已移除)
|
||||||
|
- P2-4 portal token / JWT secret 分离(已通过 P2-4 单元测试 + prod fail-closed)
|
||||||
|
- P2-5 payment webhook secret fail-closed(已通过 P2-5 单元测试 + prod fail-closed)
|
||||||
- X-02 支付域设计
|
- X-02 支付域设计
|
||||||
- X-03 Delivery 交付服务设计
|
- X-03 Delivery 交付服务设计
|
||||||
- X-04 合规基线文档
|
- X-04 合规基线文档
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
|
|
||||||
注意边界:
|
注意边界:
|
||||||
|
|
||||||
- 当前 `sent` 仍表示“站内交付物校验通过并推进事件状态”,**不是** 邮件/微信等外部渠道真实发送成功。
|
- 当前 `station` 事件的最终状态是 `validated`,表示站内交付物校验通过;只有 `email` 渠道真实发送后才进入 `delivered`。
|
||||||
- dispatcher 每次会处理 `ready` 与 `failed` 事件;如果缺少 HTML/PDF,会把事件记为 `failed`,并递增 `attempt_count`。
|
- dispatcher 每次会处理 `ready` 与 `failed` 事件;如果缺少 HTML/PDF,会把事件记为 `failed`,并递增 `attempt_count`。
|
||||||
- watchdog 复用同一 dispatch 路径,但只要本轮出现失败事件就返回 exit code `2`,适合接 systemd `OnFailure=` 或外部告警。
|
- watchdog 复用同一 dispatch 路径,但只要本轮出现失败事件就返回 exit code `2`,适合接宿主机监控或外部告警。
|
||||||
|
|
||||||
### retention cleanup
|
### retention cleanup
|
||||||
|
|
||||||
@@ -121,10 +121,7 @@ crontab -l
|
|||||||
|
|
||||||
## 7. 仍未收口的缺口
|
## 7. 仍未收口的缺口
|
||||||
|
|
||||||
这些仍然没有被本次 runbook 伪装成“已完成”:
|
1. 尚无邮件/微信真实发送执行器。
|
||||||
|
2. watchdog 的本地告警 sink 已存在,但目标主机上的真实 SMTP / webhook 联调仍未验收。
|
||||||
1. `sent` 语义仍偏向“站内交付校验完成”,不是外部渠道真实投递成功。
|
3. retention cleanup 只是后台匿名化作业,前台/客服删除工单流程仍未上线。
|
||||||
2. 尚无邮件/微信真实发送执行器。
|
4. 这些 unit/timer/cron 样例已落仓,但是否真正安装到目标生产主机,需要部署时另行执行并留存记录。
|
||||||
3. watchdog 只有失败退出码,还没有仓库内置的告警推送集成。
|
|
||||||
4. retention cleanup 只是后台匿名化作业,前台/客服删除工单流程仍未上线。
|
|
||||||
5. 这些 unit/timer/cron 样例已落仓,但是否真正安装到目标生产主机,需要部署时另行执行并留存记录。
|
|
||||||
|
|||||||
237
reports/COMPLETION_SUMMARY_2026-06-15.md
Normal file
237
reports/COMPLETION_SUMMARY_2026-06-15.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# 本轮完成项摘要报告(2026-06-15)
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本轮工作围绕 2026-06-15 系统复审报告中的 P1 / P2 整改项展开,目标是优先收口仓库内可以真实落地并可验证的风险,而不是继续停留在“文档承诺”或“计划状态”。
|
||||||
|
|
||||||
|
本轮特别聚焦四类问题:
|
||||||
|
|
||||||
|
1. portal 公开面敏感信息暴露
|
||||||
|
2. 删除 / 匿名化链路中的文件残留
|
||||||
|
3. 支付与通知域中的残留死语义
|
||||||
|
4. 隐私 / 删除入口与目标机恢复验收准备不足
|
||||||
|
|
||||||
|
## 2. 已完成项
|
||||||
|
|
||||||
|
### 2.1 Portal 通知审计页不再暴露原始 payload
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `admin/routes/web_public.py`
|
||||||
|
- `admin/tests/test_notification_audit_page.py`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- portal 通知页不再直出 `payload_json`
|
||||||
|
- 不再向持 token 用户暴露邮箱、报告绝对路径、PDF 路径
|
||||||
|
- 页面仅保留通知摘要字段
|
||||||
|
|
||||||
|
### 2.2 删除 / 匿名化链路补齐 portal 上传附件清理
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `data/orders/deletion_service.py`
|
||||||
|
- `admin/tests/test_order_deletion.py`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- `delete` 会删除 portal 上传附件
|
||||||
|
- `anonymize` 也会清理 portal 上传附件
|
||||||
|
- 空附件目录会一起删除
|
||||||
|
- 数据库 payload 仍会被清空
|
||||||
|
|
||||||
|
### 2.3 通知域移除 `sent` 旧语义
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `data/notifications/email_service.py`
|
||||||
|
- `tests/test_delivery_notification.py`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- `DELIVERY_EVENT_STATUSES` 已移除 `sent`
|
||||||
|
- `mark_sent()` 已删除
|
||||||
|
- 测试切换为 `validated / delivered / failed`
|
||||||
|
|
||||||
|
### 2.4 支付域移除 `refund_pending` 死状态
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `data/payments/dao.py`
|
||||||
|
- `data/payments/service.py`
|
||||||
|
- `admin/routes/web_public.py`
|
||||||
|
- `data/payments/tests/test_refund_flow.py`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- payment schema 不再包含 `refund_pending`
|
||||||
|
- portal 状态推导不再依赖 `refund_pending`
|
||||||
|
- 退款路径统一为真实可达的 `refunded`
|
||||||
|
|
||||||
|
### 2.5 支付回调补齐仓库内可落地的商户维度校验
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `admin/config.py`
|
||||||
|
- `data/payments/provider_requirements.py`
|
||||||
|
- `data/payments/service.py`
|
||||||
|
- `data/payments/providers/alipay.py`
|
||||||
|
- `admin/routes/web_public.py`
|
||||||
|
- `admin/tests/test_payment_alipay_notify.py`
|
||||||
|
- `data/payments/tests/test_provider_requirements.py`
|
||||||
|
- `data/payments/tests/test_provider_alipay.py`
|
||||||
|
- `data/payments/tests/test_webhook.py`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- 新增 `GAOKAO_PAYMENT_MERCHANT_ID`
|
||||||
|
- readiness 检查要求 merchant 维度
|
||||||
|
- webhook 归一化 payload 新增 `merchant_id`
|
||||||
|
- webhook 处理新增 merchant missing / mismatch 校验
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 这只完成仓库内能力补齐
|
||||||
|
- 真实公网 acceptance 仍需外部商户凭据、notify 域名和目标环境
|
||||||
|
|
||||||
|
### 2.6 测试子进程调用改为 hermetic
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `tests/test_retention_cleanup.py`
|
||||||
|
- `tests/test_delivery_dispatcher.py`
|
||||||
|
- `tests/test_t5_performance.py`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- 子进程 `python3` 调用改为 `sys.executable`
|
||||||
|
- Locust 调用改为 `python -m locust`
|
||||||
|
- 直接 `./.venv/bin/python -m pytest ...` 不再因 PATH 分裂失败
|
||||||
|
|
||||||
|
### 2.7 隐私 / 删除前台入口与后台审计页已补齐
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `admin/routes/web_public.py`
|
||||||
|
- `admin/routes/notifications.py`
|
||||||
|
- `admin/config.py`
|
||||||
|
- `admin/tests/conftest.py`
|
||||||
|
- `admin/tests/test_web_public.py`
|
||||||
|
- `admin/tests/test_order_info_form.py`
|
||||||
|
- `admin/tests/test_notifications_admin.py`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
新增公开页面:
|
||||||
|
|
||||||
|
- `/privacy`
|
||||||
|
- `/service-terms`
|
||||||
|
- `/deletion-policy`
|
||||||
|
|
||||||
|
新增 portal 删除申请最小闭环:
|
||||||
|
|
||||||
|
- `GET /portal/{token}/deletion-request`
|
||||||
|
- `POST /portal/{token}/deletion-request`
|
||||||
|
|
||||||
|
新增留痕与后台审计:
|
||||||
|
|
||||||
|
- `GAOKAO_DELETION_REQUEST_LOG`
|
||||||
|
- `GET /api/admin/notifications/deletion-requests`
|
||||||
|
- `GET /admin/deletion-requests`
|
||||||
|
|
||||||
|
### 2.8 目标机恢复验收准备已落成可执行模板
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `reports/DR_DRILL_TEMPLATE.md`
|
||||||
|
- `tests/test_backup_workflow.py`
|
||||||
|
- `docs/BACKUP_AND_RECOVERY_PLAN.md`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- 新增 DR drill 模板,可记录目标主机 / 异机恢复结果
|
||||||
|
- 测试锁定模板必须引用 `backup_verify.sh --from-backup`
|
||||||
|
- 文档已明确真实演练记录归档位置
|
||||||
|
|
||||||
|
### 2.9 文档边界同步收口
|
||||||
|
|
||||||
|
已修改:
|
||||||
|
|
||||||
|
- `docs/CURRENT_STATE.md`
|
||||||
|
- `docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
|
||||||
|
- `docs/BACKUP_AND_RECOVERY_PLAN.md`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- codecov 改为“同值展示口径”,不再误写成执行单源
|
||||||
|
- `sent` / `refund_pending` 旧口径已移除或修正
|
||||||
|
- Compose 文件头已明确其仅是开发 / 本地 smoke 模板
|
||||||
|
- backup 文档不再伪装目标机告警已经完成
|
||||||
|
|
||||||
|
## 3. 验证结果
|
||||||
|
|
||||||
|
### 3.1 针对性测试
|
||||||
|
|
||||||
|
执行过的关键针对性回归包括:
|
||||||
|
|
||||||
|
- `admin/tests/test_notification_audit_page.py`
|
||||||
|
- `admin/tests/test_order_deletion.py`
|
||||||
|
- `tests/test_delivery_notification.py`
|
||||||
|
- `data/payments/tests/test_provider_requirements.py`
|
||||||
|
- `data/payments/tests/test_provider_alipay.py`
|
||||||
|
- `data/payments/tests/test_webhook.py`
|
||||||
|
- `admin/tests/test_payment_alipay_notify.py`
|
||||||
|
- `data/payments/tests/test_refund_flow.py`
|
||||||
|
- `data/payments/tests/test_service.py`
|
||||||
|
- `tests/test_retention_cleanup.py`
|
||||||
|
- `tests/test_delivery_dispatcher.py`
|
||||||
|
- `tests/test_t5_performance.py`
|
||||||
|
- `tests/test_backup_workflow.py`
|
||||||
|
- `admin/tests/test_web_public.py`
|
||||||
|
- `admin/tests/test_order_info_form.py`
|
||||||
|
- `admin/tests/test_notifications_admin.py`
|
||||||
|
|
||||||
|
其中最后一次定向回归结果:
|
||||||
|
|
||||||
|
- `23 passed`
|
||||||
|
|
||||||
|
### 3.2 标准验证入口
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/dev-verify.sh --skip-install
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- `730 passed`
|
||||||
|
- `coverage overall = 92.70%`
|
||||||
|
- `coverage core = 100.00%`
|
||||||
|
- `ruff` 通过
|
||||||
|
- `mypy` 无错误
|
||||||
|
|
||||||
|
## 4. 本轮未在仓库内伪完成的外部阻塞
|
||||||
|
|
||||||
|
以下事项仍依赖仓库外条件,本轮没有伪装为“已完成”:
|
||||||
|
|
||||||
|
1. 真实支付宝公网 acceptance
|
||||||
|
- 仍需真实商户凭据、公网 `notify_url`、真实域名和线上演练记录
|
||||||
|
2. 目标机真实 backup / restore 演练
|
||||||
|
- 当前只完成模板与准备,不代表目标机已跑过
|
||||||
|
3. 目标机真实 SMTP / webhook 告警联调
|
||||||
|
- 当前仓库只保留真实边界说明,没有伪造不存在的 backup alert service
|
||||||
|
4. 正式法务文本最终签发
|
||||||
|
- 当前仓库已补入口与流程,但不等于外部法务审批流程已经结束
|
||||||
|
|
||||||
|
## 5. 结论
|
||||||
|
|
||||||
|
本轮工作已经把“可在仓库内真实完成并验证”的整改项全部落地。系统状态从“复审指出的问题已确认存在”推进到“主风险项已逐步切成两类”:
|
||||||
|
|
||||||
|
- **仓库内已修复并可回归验证的工程问题**
|
||||||
|
- **必须依赖外部环境、目标主机或正式业务输入才能闭环的上线前阻塞**
|
||||||
|
|
||||||
|
因此,当前仓库可以更准确地表述为:
|
||||||
|
|
||||||
|
> 已完成一轮真实工程收敛,仓库内可验证整改项已落地并通过标准验证入口;剩余阻塞集中在真实支付 acceptance、目标机 DR 演练、真实告警联调和正式外部文本签发。
|
||||||
96
reports/DR_DRILL_TEMPLATE.md
Normal file
96
reports/DR_DRILL_TEMPLATE.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# DR_DRILL_TEMPLATE
|
||||||
|
|
||||||
|
日期:
|
||||||
|
负责人:
|
||||||
|
目标主机:
|
||||||
|
备用/异机主机:
|
||||||
|
对应快照目录:
|
||||||
|
对应工单/变更号:
|
||||||
|
|
||||||
|
## 1. 演练目标
|
||||||
|
|
||||||
|
- 验证目标主机可生成真实快照
|
||||||
|
- 验证 `bash scripts/backup_verify.sh --from-backup <backup-dir>` 可在目标主机跑通
|
||||||
|
- 验证恢复副本最小服务链:
|
||||||
|
- `health_status = 200`
|
||||||
|
- `portal_status = 200`
|
||||||
|
- `portal_report = 200`
|
||||||
|
- `portal_pdf = 200`
|
||||||
|
- 记录密钥位置、恢复耗时、异常与补救动作
|
||||||
|
|
||||||
|
## 2. 前置条件
|
||||||
|
|
||||||
|
- 目标主机已部署当前版本代码
|
||||||
|
- 目标主机已配置 `.env` / secrets / backup root
|
||||||
|
- 目标主机存在可执行的 `bash scripts/backup_snapshot.sh`
|
||||||
|
- 若做异机演练,备用主机已具备读取快照与最小运行依赖
|
||||||
|
|
||||||
|
## 3. 执行记录
|
||||||
|
|
||||||
|
### 3.1 生成真实快照
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup_snapshot.sh <backup-root>
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- 快照目录:
|
||||||
|
- manifest 是否生成:
|
||||||
|
- 失败/异常:
|
||||||
|
|
||||||
|
### 3.2 从快照执行 restore smoke
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup_verify.sh --from-backup <backup-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- `manifest_ok`:
|
||||||
|
- `sqlite_ok`:
|
||||||
|
- `health_status`:
|
||||||
|
- `portal_status`:
|
||||||
|
- `portal_report`:
|
||||||
|
- `portal_pdf`:
|
||||||
|
- 耗时:
|
||||||
|
- 失败/异常:
|
||||||
|
|
||||||
|
### 3.3 异机恢复(如执行)
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup_verify.sh --from-backup <backup-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- 主机:
|
||||||
|
- 是否成功:
|
||||||
|
- 差异/异常:
|
||||||
|
|
||||||
|
## 4. 密钥与配置核对
|
||||||
|
|
||||||
|
- JWT secret 位置:
|
||||||
|
- Portal token secret 位置:
|
||||||
|
- Fernet key 位置:
|
||||||
|
- Payment key/cert 位置:
|
||||||
|
- 负责人:
|
||||||
|
|
||||||
|
## 5. 结论
|
||||||
|
|
||||||
|
- [ ] 目标主机快照成功
|
||||||
|
- [ ] 目标主机 restore smoke 成功
|
||||||
|
- [ ] 异机恢复成功(若适用)
|
||||||
|
- [ ] 可归档为上线前灾备演练证据
|
||||||
|
|
||||||
|
## 6. 后续动作
|
||||||
|
|
||||||
|
- 待修复问题:
|
||||||
|
- 风险等级:
|
||||||
|
- 下一次演练时间:
|
||||||
507
reports/PROJECT_SYSTEM_REVIEW_2026-06-15.md
Normal file
507
reports/PROJECT_SYSTEM_REVIEW_2026-06-15.md
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
# gaokao-volunteer-system 系统性复审报告(2026-06-15)
|
||||||
|
|
||||||
|
**评审对象**: `/home/long/project/gaokao-volunteer-system`
|
||||||
|
**复审基线**: `docs/CURRENT_STATE.md`(最后更新 2026-06-15)
|
||||||
|
**评审方式**: 当前真相源复核 + 核心源码抽检 + 子系统专项复审 + 本地验证命令复核
|
||||||
|
**评审标准**: 严格标准;重点看“真实闭环、数据安全、验证可信度、生产可运维性”
|
||||||
|
**历史参照**: `reports/PROJECT_SYSTEM_REVIEW_2026-06-14.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 复审结论
|
||||||
|
|
||||||
|
**结论**:相较 2026-06-14,上轮多项高优先级工程问题已经真实收口,尤其是:
|
||||||
|
|
||||||
|
- 支付 webhook 双写裂缝已关闭
|
||||||
|
- 退款主链闭环已关闭
|
||||||
|
- webhook DB 按 `db_path` 隔离已关闭
|
||||||
|
- 删除/匿名化按当前声明范围已扩围
|
||||||
|
- 分享 allowlist 已落地
|
||||||
|
- portal token secret / payment webhook secret 的 prod fail-closed 已落地
|
||||||
|
- CI / dev-verify / coverage gate 的**本地硬门禁**已统一
|
||||||
|
- 备份恢复已从文件级升级到**本地服务级 restore smoke**
|
||||||
|
|
||||||
|
但按严格标准,项目**仍然不能被表述为“完整商业闭环系统”或“支付/灾备/合规都已完成生产验收”**。当前仍存在两类关键未闭环问题:
|
||||||
|
|
||||||
|
1. **真实生产闭环未完成**:真实支付宝 acceptance、公网 notify、生产 SMTP/IM、目标机备份恢复、异机灾备。
|
||||||
|
2. **新增或遗留的语义/暴露面风险**:portal 通知审计页暴露原始 payload、portal 上传附件未纳入删除/匿名化、`refund_pending` 死状态残留、通知底座仍保留 `sent` 旧语义。
|
||||||
|
|
||||||
|
**总体评级**:**工程基础明显增强,当前更接近“本地可验证的 v2.1 运营增强系统 + T12 在途”,但仍不应宣称生产级 Web 自助闭环已完成。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 本次复审回答的核心问题
|
||||||
|
|
||||||
|
### 2.1 已确认关闭的旧问题
|
||||||
|
|
||||||
|
- P1-1 支付双写裂缝
|
||||||
|
- P1-4 webhook DB 连接污染
|
||||||
|
- P1-5 退款闭环
|
||||||
|
- P1-2 删除/匿名化扩围(按 CURRENT_STATE 声称范围)
|
||||||
|
- P1-6 分享 allowlist
|
||||||
|
- P2-1 孤儿订单
|
||||||
|
- P2-2 channel_sync DAO 真相收敛(实现层面)
|
||||||
|
- P2-4 portal token secret 分离
|
||||||
|
- P2-5 payment webhook secret fail-closed
|
||||||
|
- P1-7 覆盖率硬门禁本地统一
|
||||||
|
- P1-8 本地 restore smoke 升级为服务级验证
|
||||||
|
|
||||||
|
### 2.2 仍未关闭的旧问题
|
||||||
|
|
||||||
|
- P1-3 真实支付宝回调业务校验 / acceptance
|
||||||
|
- 生产备份恢复闭环
|
||||||
|
- 生产 SMTP / IM / 告警联调
|
||||||
|
- 前台正式隐私/删除/工单闭环
|
||||||
|
|
||||||
|
### 2.3 本次新增发现
|
||||||
|
|
||||||
|
- portal 通知审计页向持 token 用户暴露原始 payload,含邮箱与服务器绝对路径
|
||||||
|
- portal 上传附件未纳入删除/匿名化文件清理
|
||||||
|
- `refund_pending` 已退化为死状态,模型口径继续分裂
|
||||||
|
- 通知底座仍保留 `sent` 状态,P2-3 仅在 dispatcher / portal 主链层完成
|
||||||
|
- `CURRENT_STATE` 对 codecov 的“单一真相源”表述偏强;实质是数值对齐,不是执行单源
|
||||||
|
- Docker / Compose 仍是开发态模板,不能代表生产部署闭环
|
||||||
|
- 测试/脚本对子进程 PATH 有隐式依赖;直接 `.venv/bin/python -m pytest` 与激活 venv 后的 `python3`/`locust` 行为不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 评审范围
|
||||||
|
|
||||||
|
### 3.1 文档
|
||||||
|
|
||||||
|
- `docs/CURRENT_STATE.md`
|
||||||
|
- `docs/P0_P1_P2_REMEDIATION_PLAN_2026-06-14.md`
|
||||||
|
- `docs/BACKUP_AND_RECOVERY_PLAN.md`
|
||||||
|
- `docs/PRODUCTION_DEPLOYMENT_CHECKLIST_2026-06-15.md`
|
||||||
|
- `docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
|
||||||
|
- `codecov.yml`
|
||||||
|
- `.github/workflows/ci.yml`
|
||||||
|
- `docker-compose.yml`
|
||||||
|
- `Dockerfile`
|
||||||
|
|
||||||
|
### 3.2 代码主链
|
||||||
|
|
||||||
|
- `data/payments/service.py`
|
||||||
|
- `data/payments/providers/alipay.py`
|
||||||
|
- `data/channel_sync/webhook_server.py`
|
||||||
|
- `data/orders/deletion_service.py`
|
||||||
|
- `data/share/permission.py`
|
||||||
|
- `data/notifications/dispatcher.py`
|
||||||
|
- `data/notifications/email_service.py`
|
||||||
|
- `admin/config.py`
|
||||||
|
- `admin/routes/web_public.py`
|
||||||
|
- `data/customer_portal/token.py`
|
||||||
|
|
||||||
|
### 3.3 关键测试样本
|
||||||
|
|
||||||
|
- `admin/tests/test_p2_4_p2_5_secrets.py`
|
||||||
|
- `admin/tests/test_order_deletion.py`
|
||||||
|
- `admin/tests/test_web_public.py`
|
||||||
|
- `admin/tests/test_payment_alipay_notify.py`
|
||||||
|
- `admin/tests/test_notification_audit_page.py`
|
||||||
|
- `data/payments/tests/test_webhook.py`
|
||||||
|
- `data/payments/tests/test_service.py`
|
||||||
|
- `data/payments/tests/test_refund_flow.py`
|
||||||
|
- `data/channel_sync/tests/test_webhook_server_db_scoping.py`
|
||||||
|
- `data/channel_sync/tests/test_single_dao_truth.py`
|
||||||
|
- `tests/test_backup_restore_service_level.py`
|
||||||
|
- `tests/test_coverage_gate_core.py`
|
||||||
|
- `tests/test_ops_alerts.py`
|
||||||
|
- `tests/test_delivery_notification.py`
|
||||||
|
- `tests/test_retention_cleanup.py`
|
||||||
|
- `tests/test_delivery_dispatcher.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 严重级别定义
|
||||||
|
|
||||||
|
- **P0**: 可直接导致系统失控/严重泄露/资金错误,且无现实缓解
|
||||||
|
- **P1**: 阻止生产闭环或存在显著数据/支付/隐私风险
|
||||||
|
- **P2**: 语义漂移、工程债务、生产边界不清或中等级暴露面
|
||||||
|
- **P3**: 已修复旧问题 / 正向发现 / 低优先改进项
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 关键发现
|
||||||
|
|
||||||
|
## 5.1 P1:仍然成立的问题
|
||||||
|
|
||||||
|
### P1-1 真实支付宝回调校验与 acceptance 仍未闭环
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `docs/CURRENT_STATE.md:25,79,142-145` 继续将真实支付 acceptance / P1-3 列为未完成阻塞项。
|
||||||
|
- `data/payments/service.py:189-215` 当前已校验:签名、`payment_id`、成功状态白名单、金额、`app_id`、`notify_id` 非空、`provider_trade_no` 非空。
|
||||||
|
- `data/payments/providers/alipay.py:97-110` 的标准化字段中仍没有 merchant/seller 维度。
|
||||||
|
- `docs/P0_P1_P2_REMEDIATION_PLAN_2026-06-14.md:223-234` 明确要求 `app_id / merchant / notify_id / 状态白名单 / 金额 / payment_id` 全校验。
|
||||||
|
- `admin/tests/test_payment_alipay_notify.py:30-136` 使用临时 RSA keypair 与 `https://example.com/...` 回调地址,仅证明本地闭环,不是商户实联。
|
||||||
|
|
||||||
|
**判断**
|
||||||
|
|
||||||
|
相较上轮,这一项已**部分修复**,但仍是当前最重要的生产阻塞项之一。当前代码不支持把“本地可测”升级表述成“真实支付闭环已完成”。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
- 真实商户/真实 notify 链尚无线上验收
|
||||||
|
- 缺 merchant/seller 绑定维度
|
||||||
|
- `notify_id` 只做非空校验,不是远端真实性证明
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
|
||||||
|
正式报告必须继续把该项列为 **P1 未关闭**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-2 Portal 通知审计页向持 token 用户暴露原始通知 payload
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `admin/routes/web_public.py:1148-1189` 的 `_render_notification_audit_page()` 直接渲染 `event.payload_json`。
|
||||||
|
- `data/orders/dao.py:583-595` 在交付事件中把 `audit_report` / `pdf_path` 写入 payload。
|
||||||
|
- `tests/test_delivery_notification.py:124-129` 明确断言 email event payload 中包含 `parent@example.com`。
|
||||||
|
- `admin/tests/test_notification_audit_page.py:47-85` 仅验证页面能显示事件,不校验敏感字段被裁剪。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
任何持有 portal token 的用户都可在 `/portal/{token}/notifications` 查看:
|
||||||
|
|
||||||
|
- 客户邮箱
|
||||||
|
- 交付物服务器绝对路径
|
||||||
|
- 原始通知 payload 细节
|
||||||
|
|
||||||
|
这扩大了暴露面,也泄露了服务器目录结构。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
|
||||||
|
这是本次复审最重要的**新增 P1**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-3 Portal 上传附件未纳入删除/匿名化文件清理
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `admin/routes/web_public.py:286-303` `_store_portal_attachment()` 把附件写入 `portal_upload_dir/order_id/...`,并把 `storage_path` 写入 intake payload。
|
||||||
|
- `data/orders/deletion_service.py:49-69, 71-126, 157-166` 删除时仅删除 `audit_report` 与 `pdf_path`;匿名化仅清空 `payload_json`,不删除附件目录。
|
||||||
|
- `admin/tests/test_order_info_upload.py` 证明上传后的 `storage_path` 会真实落盘。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
数据库层面看似“已删除/已匿名化”,但磁盘上可能仍保留:
|
||||||
|
|
||||||
|
- 身份证/成绩单等高敏附件
|
||||||
|
- 其他 AI 方案或家庭信息材料
|
||||||
|
|
||||||
|
这会把“声明层已清理”与“真实文件仍在”重新撕裂开。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
|
||||||
|
这是隐私删除链上的**新增 P1**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-4 生产备份恢复仍未验收;当前只能证明本地 restore smoke 成立
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `docs/BACKUP_AND_RECOVERY_PLAN.md:145-159,194-199` 明确承认:异机/异地备份、目标主机 timer、失败告警链、密钥轮换记录仍未闭环。
|
||||||
|
- `docs/PRODUCTION_DEPLOYMENT_CHECKLIST_2026-06-15.md:275-295` 把目标机 `backup_verify.sh --from-backup ...` 仍列为上线前待勾选项。
|
||||||
|
- `ops/systemd/gaokao-backup*.service|timer` 与 `ops/cron/gaokao-backup.crontab.example` 仅是样例,没有目标主机执行证据。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
当前仓库可以证明“本地可演练”,不能证明:
|
||||||
|
|
||||||
|
- 目标主机已持续生成可恢复快照
|
||||||
|
- 异机恢复已验证
|
||||||
|
- 密钥与配置已能回收
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
|
||||||
|
这项应继续保留为 **P1 生产闭环缺口**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1-5 备份定时任务没有失败告警闭环
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `ops/systemd/gaokao-backup.service` / `gaokao-backup-verify.service` 仅有 `ExecStart`,未见 `OnFailure=`。
|
||||||
|
- `ops/cron/gaokao-backup.crontab.example` 只把输出写日志,不接 SMTP/IM/监控。
|
||||||
|
- `docs/BACKUP_AND_RECOVERY_PLAN.md:186-196` 流程图写到“告警”,但 `154-159` 又明确承认备份失败告警链未接入。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
备份或 restore smoke 失败时,当前样例部署只会留下日志,不会主动通知值班。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
|
||||||
|
这是备份链路上的 **P1 运维缺口**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 P2:新增风险 / 未完全收口的问题
|
||||||
|
|
||||||
|
### P2-1 `refund_pending` 已退化为死状态,模型口径继续分裂
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `data/payments/dao.py` schema 仍允许 `refund_pending`
|
||||||
|
- `admin/routes/web_public.py:45-55` 仍渲染 `refund_pending`
|
||||||
|
- `data/payments/service.py:261-300` 实际退款入口已直接把 payment 更新为 `refunded`,无任何路径写入 `refund_pending`
|
||||||
|
- `data/payments/tests/test_refund_flow.py` / `test_service.py` 也已按直接 `refunded` 锁定
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
这是死状态,不再是主链 bug,但会继续污染 UI、报表、维护者心智模型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-2 `sent` 旧语义仍残留在通知底座
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `data/notifications/dispatcher.py` 已把 `station` 改为 `validated`,`email` 才进入 `delivered`
|
||||||
|
- 但 `data/notifications/email_service.py:29` 的 `DELIVERY_EVENT_STATUSES` 仍含 `sent`
|
||||||
|
- `mark_sent()` 仍可直接写入 `sent`,见 `data/notifications/email_service.py:131-151`
|
||||||
|
- `tests/test_delivery_notification.py` 仍把 `sent` 视作合法状态路径
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
主链页面已纠偏,但底座状态空间仍允许旧语义复活。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-3 `CURRENT_STATE` 对 codecov 的“单一真相源”表述偏强
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `docs/CURRENT_STATE.md:54-57` 声称 `scripts/check_coverage_gate.py` 是单一真相源,CI/dev-verify/codecov 全部指向同一阈值。
|
||||||
|
- `codecov.yml:13-29` 实际上单独重复维护 target/threshold/patch target。
|
||||||
|
- `codecov.yml:6,18,45-47` 还显式表明它不是硬阻断门禁。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
这不是门禁失效问题,而是表述失真问题。更准确的说法应是:
|
||||||
|
|
||||||
|
- **CI 与本地硬门禁统一**
|
||||||
|
- **Codecov 仅与其保持同值显示口径,不是执行单源**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-4 Docker / Compose 仍是开发态模板,不应视为生产部署闭环
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `docker-compose.yml:9-18` 未编码 checklist 所要求的大量生产变量:`GAOKAO_PORTAL_TOKEN_SECRET`、支付回调、SMTP、告警变量等。
|
||||||
|
- `docker-compose.yml:10-14` 仍默认 `GAOKAO_ENV=dev` 且带 dev fallback。
|
||||||
|
- `Dockerfile` 只构建 admin 进程镜像,不包含 dispatcher/watchdog/backup 作业编排。
|
||||||
|
- `docs/PRODUCTION_DEPLOYMENT_CHECKLIST_2026-06-15.md:27-79` 明确把这些生产变量列为必备。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
现有 Compose 只能证明“开发环境可跑”,不能证明“生产最小闭环可跑”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2-5 测试/脚本对子进程 PATH 有隐式依赖
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
本次实际运行观察到:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python -m pytest admin/tests tests data -q
|
||||||
|
```
|
||||||
|
|
||||||
|
出现 7 个失败,主要为:
|
||||||
|
|
||||||
|
- `tests/test_delivery_dispatcher.py`
|
||||||
|
- `tests/test_retention_cleanup.py`
|
||||||
|
- `tests/test_t5_performance.py`
|
||||||
|
|
||||||
|
失败原因:
|
||||||
|
|
||||||
|
- 这些测试用 `subprocess.run(["python3", ...])` 或 `locust`,直接依赖 PATH 上的解释器/可执行文件。
|
||||||
|
- 未激活 venv 时,`python3` 会落到系统 Python,随后因 `admin.app -> import uvicorn` 失败。
|
||||||
|
|
||||||
|
而运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/dev-verify.sh --skip-install
|
||||||
|
```
|
||||||
|
|
||||||
|
则 **717 passed**,因为脚本先 `source .venv/bin/activate`,PATH 被修正,子进程 `python3`/`locust` 都能解析到 venv 内可执行文件。
|
||||||
|
|
||||||
|
**风险**
|
||||||
|
|
||||||
|
这不是业务 bug,但说明验证闭环对“shell 激活 venv”有隐式前提;直接 `.venv/bin/python -m pytest` 还不够 hermetic。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3 P3:已确认关闭的旧问题 / 正向发现
|
||||||
|
|
||||||
|
### P3-1 支付双写裂缝已关闭
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `data/payments/service.py:217-259` 在同一事务中同时推进 payment 与 order
|
||||||
|
- `data/payments/tests/test_service.py` 覆盖回滚场景
|
||||||
|
- 专项验证:`34 passed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-2 退款主链闭环已关闭
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `data/payments/service.py:261-300` 在同一事务中推进 payment 与 order 到 `refunded`
|
||||||
|
- `data/payments/tests/test_refund_flow.py` / `test_service.py` 已锁定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-3 webhook DB 隔离已关闭
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `data/channel_sync/webhook_server.py:122-167` 改为按 `db_path` 缓存连接
|
||||||
|
- `data/channel_sync/tests/test_webhook_server_db_scoping.py` 覆盖多路径与并发场景
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-4 删除/匿名化按当前声明范围已扩围
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `data/orders/deletion_service.py:71-125` 已覆盖 orders 主表、`payments.callback_payload`、`order_intakes.payload_json`
|
||||||
|
- `admin/tests/test_order_deletion.py` 已覆盖关键断言
|
||||||
|
|
||||||
|
**边界**
|
||||||
|
|
||||||
|
这只能证明“按 CURRENT_STATE 声称范围已修复”,**不等于完整合规闭环**。portal 上传附件仍未清理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-5 分享 allowlist 已收口
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `data/share/permission.py:45-47, 85-173` edit/admin 均改为显式 allowlist
|
||||||
|
- 未知 permission 会 fail-safe 到 `read`
|
||||||
|
- `data/share/tests/test_permission.py` 已覆盖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-6 portal token secret 分离与 payment webhook secret fail-closed 已落地
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `admin/config.py` 已引入 `portal_token_secret` 与 prod 校验
|
||||||
|
- `admin/tests/test_p2_4_p2_5_secrets.py` 覆盖完整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-7 覆盖率硬门禁本地统一已成立
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `scripts/check_coverage_gate.py`
|
||||||
|
- `scripts/dev-verify.sh`
|
||||||
|
- `.github/workflows/ci.yml`
|
||||||
|
- `tests/test_coverage_gate_core.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-8 本地 restore smoke 已从文件级升级到服务级
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `scripts/backup_verify.sh`
|
||||||
|
- `scripts/backup_restore_smoke.py`
|
||||||
|
- `tests/test_backup_restore_service_level.py`
|
||||||
|
- `tests/test_backup_workflow.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3-9 watchdog → ops alert sink → 后台审计页本地链已存在
|
||||||
|
|
||||||
|
**证据**
|
||||||
|
|
||||||
|
- `scripts/gaokao-delivery-watchdog.py`
|
||||||
|
- `data/notifications/ops_alerts.py`
|
||||||
|
- `admin/routes/notifications.py`
|
||||||
|
- `tests/test_ops_alerts.py`
|
||||||
|
- `admin/tests/test_ops_alerts_admin.py`
|
||||||
|
|
||||||
|
**边界**
|
||||||
|
|
||||||
|
这证明本地底座存在;**不等于真实 SMTP/IM 与宿主机联调完成**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 命令复核结果
|
||||||
|
|
||||||
|
### 6.1 全量直接 pytest(不激活 venv PATH)
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python -m pytest admin/tests tests data -q
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:**7 failed,710 passed**。
|
||||||
|
|
||||||
|
主要失败原因:
|
||||||
|
|
||||||
|
- `tests/test_delivery_dispatcher.py` / `tests/test_retention_cleanup.py` 中子进程调用 `python3`,落到系统解释器,触发 `ModuleNotFoundError: uvicorn`
|
||||||
|
- `tests/test_t5_performance.py` 依赖 `locust` 可执行文件在 PATH 中可见
|
||||||
|
|
||||||
|
### 6.2 仓库标准验证入口
|
||||||
|
|
||||||
|
执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/dev-verify.sh --skip-install
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:**通过**,关键输出:
|
||||||
|
|
||||||
|
- `717 passed`
|
||||||
|
- `coverage gate summary: overall=92.59%, core=100.00%`
|
||||||
|
- `ruff`: 通过
|
||||||
|
- `mypy`: `Success: no issues found in 187 source files`
|
||||||
|
|
||||||
|
### 6.3 解释
|
||||||
|
|
||||||
|
- 仓库声明的标准入口 `dev-verify.sh` 当前是可通过的。
|
||||||
|
- 但验证闭环依赖 `source .venv/bin/activate` 改写 PATH;测试本身仍不够完全自描述。
|
||||||
|
- 这更像“验证入口已统一,但底层 shell 假设仍存在”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 最终判断
|
||||||
|
|
||||||
|
### 可以确认的事实
|
||||||
|
|
||||||
|
1. 上轮多项工程性 P1/P2 已真实修复,不应再按“当前未修复”表述。
|
||||||
|
2. 当前版本已经明显强于 2026-06-14 的状态,尤其在事务一致性、安全 fail-closed、本地恢复演练方面。
|
||||||
|
3. 但项目**仍不能**宣称:
|
||||||
|
- 真实支付闭环已完成
|
||||||
|
- 灾备已完成生产验收
|
||||||
|
- 合规删除链已完整闭环
|
||||||
|
- 通知/交付语义已完全收敛
|
||||||
|
|
||||||
|
### 当前最关键的未闭环项
|
||||||
|
|
||||||
|
按风险排序:
|
||||||
|
|
||||||
|
1. **真实支付宝回调业务校验 + 线上 acceptance**
|
||||||
|
2. **portal 通知审计页 payload 直出导致的敏感信息暴露**
|
||||||
|
3. **portal 上传附件未纳入删除/匿名化文件清理**
|
||||||
|
4. **生产备份恢复 / 失败告警 / 目标机验收缺失**
|
||||||
|
5. **`refund_pending` / `sent` 等残留死语义收敛**
|
||||||
|
6. **Docker / Compose 与生产 checklist 的边界澄清**
|
||||||
|
|
||||||
|
### 一句话结论
|
||||||
|
|
||||||
|
> 当前仓库已经完成一轮真实有效的工程收敛,属于“本地可验证的 v2.1 运营增强系统 + T12 在途”,但仍不是可以对外宣称“生产级 Web 自助支付/交付/灾备/合规全闭环”的系统。
|
||||||
@@ -120,3 +120,19 @@ def test_backup_verify_runs_restore_smoke_on_snapshot(settings, tmp_path):
|
|||||||
assert '"portal_report": 200' in proc.stdout
|
assert '"portal_report": 200' in proc.stdout
|
||||||
assert '"portal_pdf": 200' in proc.stdout
|
assert '"portal_pdf": 200' in proc.stdout
|
||||||
assert "backup verification finished" in proc.stdout
|
assert "backup verification finished" in proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_dr_drill_template_exists_and_references_target_machine_acceptance():
|
||||||
|
report = PROJECT_ROOT / "reports" / "DR_DRILL_TEMPLATE.md"
|
||||||
|
assert report.is_file()
|
||||||
|
body = report.read_text(encoding="utf-8")
|
||||||
|
assert "目标主机" in body
|
||||||
|
assert "backup_verify.sh --from-backup" in body
|
||||||
|
assert "portal_status" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_plan_references_dr_drill_template():
|
||||||
|
doc = (PROJECT_ROOT / "docs" / "BACKUP_AND_RECOVERY_PLAN.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "reports/DR_DRILL_TEMPLATE.md" in doc
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from data.notifications.email_service import DeliveryNotificationService
|
from data.notifications.email_service import DeliveryNotificationService
|
||||||
@@ -261,7 +262,7 @@ def test_delivery_dispatch_script_prints_summary(settings, tmp_path):
|
|||||||
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
|
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
|
||||||
}
|
}
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["python3", "scripts/gaokao-delivery-dispatch.py", "--channel", "station"],
|
[sys.executable, "scripts/gaokao-delivery-dispatch.py", "--channel", "station"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -288,7 +289,7 @@ def test_delivery_watchdog_exits_zero_when_no_failures(settings, tmp_path):
|
|||||||
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
|
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
|
||||||
}
|
}
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["python3", "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
|
[sys.executable, "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -318,7 +319,7 @@ def test_delivery_watchdog_exits_nonzero_when_failures_detected(settings, tmp_pa
|
|||||||
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
|
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
|
||||||
}
|
}
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["python3", "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
|
[sys.executable, "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ def test_dao_delivered_transition_also_creates_notification_event(settings, tmp_
|
|||||||
assert "parent@example.com" in email_event.payload_json
|
assert "parent@example.com" in email_event.payload_json
|
||||||
|
|
||||||
|
|
||||||
def test_delivery_notification_tracks_failure_and_sent_status(settings, tmp_path):
|
def test_delivery_notification_tracks_failure_and_validated_status(settings, tmp_path):
|
||||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-NOTIFY-STATUS")
|
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-NOTIFY-STATUS")
|
||||||
_mark_paid(settings, order)
|
_mark_paid(settings, order)
|
||||||
IntakeStore.for_db(settings.orders_db_path).save(
|
IntakeStore.for_db(settings.orders_db_path).save(
|
||||||
@@ -162,11 +162,18 @@ def test_delivery_notification_tracks_failure_and_sent_status(settings, tmp_path
|
|||||||
assert failed.failure_reason == "smtp timeout"
|
assert failed.failure_reason == "smtp timeout"
|
||||||
assert failed.last_attempt_at is not None
|
assert failed.last_attempt_at is not None
|
||||||
|
|
||||||
notification_service.mark_sent(order.id)
|
notification_service.mark_validated(order.id)
|
||||||
sent = notification_service.list_events(order.id)[0]
|
validated = notification_service.list_events(order.id)[0]
|
||||||
assert sent.status == "sent"
|
assert validated.status == "validated"
|
||||||
assert sent.attempt_count == 2
|
assert validated.attempt_count == 2
|
||||||
assert sent.failure_reason is None
|
assert validated.failure_reason == "smtp timeout"
|
||||||
assert sent.last_attempt_at is not None
|
assert validated.last_attempt_at is not None
|
||||||
finally:
|
finally:
|
||||||
notification_service.close()
|
notification_service.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_notification_service_excludes_sent_legacy_status():
|
||||||
|
from data.notifications import email_service
|
||||||
|
|
||||||
|
assert "sent" not in email_service.DELIVERY_EVENT_STATUSES
|
||||||
|
assert not hasattr(email_service.DeliveryNotificationService, "mark_sent")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from data.orders.dao import OrdersDAO
|
from data.orders.dao import OrdersDAO
|
||||||
@@ -76,7 +77,7 @@ def test_retention_cleanup_script_prints_summary(settings):
|
|||||||
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[
|
[
|
||||||
"python3",
|
sys.executable,
|
||||||
"scripts/gaokao-retention-cleanup.py",
|
"scripts/gaokao-retention-cleanup.py",
|
||||||
"--cutoff",
|
"--cutoff",
|
||||||
"2025-06-30T00:00:00+00:00",
|
"2025-06-30T00:00:00+00:00",
|
||||||
@@ -101,7 +102,7 @@ def test_retention_cleanup_script_supports_retention_days(settings):
|
|||||||
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[
|
[
|
||||||
"python3",
|
sys.executable,
|
||||||
"scripts/gaokao-retention-cleanup.py",
|
"scripts/gaokao-retention-cleanup.py",
|
||||||
"--retention-days",
|
"--retention-days",
|
||||||
"180",
|
"180",
|
||||||
@@ -126,7 +127,7 @@ def test_retention_cleanup_underscore_script_alias_works(settings):
|
|||||||
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[
|
[
|
||||||
"python3",
|
sys.executable,
|
||||||
"scripts/gaokao_retention_cleanup.py",
|
"scripts/gaokao_retention_cleanup.py",
|
||||||
"--cutoff",
|
"--cutoff",
|
||||||
"2025-06-30T00:00:00+00:00",
|
"2025-06-30T00:00:00+00:00",
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"""T5.2 性能与并发测试。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
@@ -129,6 +127,8 @@ def test_admin_locust_10_concurrency_success_rate_above_95(
|
|||||||
) -> None:
|
) -> None:
|
||||||
report_prefix = tmp_path / "t5_2"
|
report_prefix = tmp_path / "t5_2"
|
||||||
command = [
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
"locust",
|
"locust",
|
||||||
"-f",
|
"-f",
|
||||||
str(LOCUST_FILE),
|
str(LOCUST_FILE),
|
||||||
|
|||||||
Reference in New Issue
Block a user