feat(web): tighten portal privacy and cleanup flows
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-06-15 21:24:55 +08:00
parent 69b74241ae
commit c03ed7a4ba
31 changed files with 1523 additions and 125 deletions

View File

@@ -47,6 +47,7 @@ class Settings:
payment_notify_url: str
payment_return_url: str
payment_app_id: str
payment_merchant_id: str
payment_private_key_path: str
payment_alipay_public_key_path: str
smtp_host: str
@@ -59,6 +60,7 @@ class Settings:
alert_recipients: list[str]
alert_webhook_urls: list[str]
ops_alert_log_path: str
deletion_request_log_path: str
jwt_secret: str
portal_token_secret: str # P2-4: 与后台 jwt_secret 分离
jwt_algorithm: str
@@ -164,6 +166,7 @@ def load_settings() -> Settings:
- GAOKAO_PAYMENT_NOTIFY_URL : 真实 provider 异步通知地址,默认空
- GAOKAO_PAYMENT_RETURN_URL : 真实 provider 浏览器返回地址,默认空
- GAOKAO_PAYMENT_APP_ID : 真实 provider 应用 ID默认空
- GAOKAO_PAYMENT_MERCHANT_ID : 真实 provider 商户/卖家 ID默认空
- GAOKAO_PAYMENT_PRIVATE_KEY_PATH : 真实 provider 私钥路径,默认空
- GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH : 支付宝公钥路径,默认空
- GAOKAO_SMTP_HOST : SMTP 主机,默认空
@@ -202,6 +205,7 @@ def load_settings() -> Settings:
payment_notify_url=os.getenv("GAOKAO_PAYMENT_NOTIFY_URL", ""),
payment_return_url=os.getenv("GAOKAO_PAYMENT_RETURN_URL", ""),
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_alipay_public_key_path=os.getenv(
"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_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"),
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),
portal_token_secret=_resolve_portal_token_secret(
os.getenv("GAOKAO_ENV", "dev")

View File

@@ -50,6 +50,92 @@ class OpsAlertListResponse(BaseModel):
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="运维告警列表")
def list_ops_alerts(
limit: int = Query(50, ge=1, le=200),

View File

@@ -49,7 +49,6 @@ _STAGE_META: dict[str, tuple[str, str]] = {
"processing": ("处理中", "后台已接单,正在生成审核/方案结果。"),
"report_ready": ("报告已就绪", "已可站内查看报告并下载 PDF。"),
"completed": ("已完成", "订单已完成,后续可继续查看历史交付内容。"),
"refund_pending": ("退款申请中", "退款申请已登记,等待处理结果。"),
"refunded": ("已退款", "该订单已完成退款。"),
"payment_failed": ("支付失败", "支付未成功,请重新发起支付。"),
}
@@ -93,6 +92,40 @@ class PortalAttachmentUploaded(BaseModel):
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)
def landing_page() -> HTMLResponse:
return HTMLResponse(_render_landing_page())
@@ -259,7 +292,6 @@ def _assert_portal_info_mutable(stage: str) -> None:
"processing",
"report_ready",
"completed",
"refund_pending",
"refunded",
}:
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)
def notification_audit_page(
token: str, settings: Settings = Depends(get_settings_dep)
@@ -463,6 +518,7 @@ def _payment_service(settings: Settings) -> PaymentService:
notify_url=settings.payment_notify_url,
return_url=settings.payment_return_url,
app_id=settings.payment_app_id,
merchant_id=settings.payment_merchant_id,
private_key_path=settings.payment_private_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_pdf_ready = bool(order.pdf_path and Path(order.pdf_path).is_file())
report_artifacts_ready = report_html_ready and report_pdf_ready
if payment is not None and payment.status == "refund_pending":
stage = "refund_pending"
elif order.status == "refunded" or (
if order.status == "refunded" or (
payment is not None and payment.status == "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:
return """<!doctype html>
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>高考志愿填报智能系统 - 用户端 Web 自助服务</title>
<style>
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; }
.hero { display: grid; gap: 20px; }
h1 { margin: 0; font-size: 42px; }
.sub { color: #9fb0d0; line-height: 1.7; }
.btn { display:inline-flex; padding:12px 18px; border-radius:12px; text-decoration:none; font-weight:700; }
.btn-primary { background:#7c9cff; 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; }
.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; }
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; }}
.hero {{ display: grid; gap: 20px; }}
h1 {{ margin: 0; font-size: 42px; }}
.sub {{ color: #9fb0d0; line-height: 1.7; }}
.btn {{ display:inline-flex; padding:12px 18px; border-radius:12px; text-decoration:none; font-weight:700; }}
.btn-primary {{ background:#7c9cff; 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; }}
.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; }}
</style>
</head>
<body>
@@ -620,6 +735,7 @@ def _render_landing_page() -> str:
<li>站内查看报告 + PDF 交付</li>
</ul>
</section>
{_render_footer_links()}
</main>
</body>
</html>
@@ -627,20 +743,20 @@ def _render_landing_page() -> str:
def _render_pricing_page() -> str:
return """<!doctype html>
return f"""<!doctype html>
<html lang=\"zh-CN\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>服务套餐 - 高考志愿填报智能系统</title>
<style>
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; }
.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); }
.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; }
.notice { margin-top:26px; padding:16px 18px; border-radius:14px; background:#fff7e6; color:#8a5a00; border:1px solid #f4d39b; }
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; }}
.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); }}
.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; }}
.notice {{ margin-top:26px; padding:16px 18px; border-radius:14px; background:#fff7e6; color:#8a5a00; border:1px solid #f4d39b; }}
</style>
</head>
<body>
@@ -665,12 +781,11 @@ def _render_pricing_page() -> str:
</article>
</section>
<div class=\"notice\">支付接入建设中:当前使用 Mock 支付沙箱完成本地闭环验证,后续可切换真实 provider。</div>
{_render_footer_links()}
</main>
</body>
</html>
"""
def _render_checkout_page(service_version: str) -> str:
amount = _SERVICE_PRICES[service_version]
service_label = {
@@ -708,6 +823,7 @@ def _render_checkout_page(service_version: str) -> str:
<button type=\"submit\">创建订单并去支付(¥{amount / 100:.0f}</button>
</form>
<p id=\"result\"></p>
{_render_footer_links()}
</section>
</main>
<script>
@@ -943,8 +1059,9 @@ def _render_info_page(
<button type=\"button\" id=\"submit-step\" style=\"display:none;\" onclick=\"submitIntake('submit')\">提交资料</button>
</div>
</form>
<p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></p>
<pre id=\"result\"></pre>
<p><a href="/portal/{escape(token)}/status">返回订单状态页</a></p>
{_render_footer_links(token)}
<pre id="result"></pre>
</main>
<script>
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:
rows = []
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 "-"))
rows.append(
"<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.last_attempt_at or '-'))}</td>"
f"<td>{failure_reason}</td>"
f"<td><pre style='white-space:pre-wrap;max-width:520px'>{payload_preview}</pre></td>"
"</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>
<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;\">
@@ -1175,13 +1285,14 @@ def _render_notification_audit_page(token: str, order: Order, events: list[Any])
<h1>通知审计</h1>
<p>订单号:{escape(order.id)}</p>
<p>服务版本:{escape(order.service_version)}</p>
<p>仅展示通知摘要;原始 payload 与附件路径不会在前台显示。</p>
<p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></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><th>payload</th>
<th>渠道</th><th>事件</th><th>状态</th><th>尝试次数</th><th>最后尝试时间</th><th>失败原因</th>
</tr>
</thead>
<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")
return f"<html><body><h1>志愿方案报告</h1><pre>{escape(content)}</pre></body></html>"
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>"""

View File

@@ -40,6 +40,7 @@ def settings(tmp_path, secure_secret, monkeypatch):
share_report_dir = str(tmp_path / "share_reports")
portal_upload_dir = str(tmp_path / "portal_uploads")
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_DB_PATH", 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_JWT_SECRET", secure_secret)
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
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

View File

@@ -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_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):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-NOTIFY-LINK")

View File

@@ -64,17 +64,37 @@ def _prepare_order_with_artifacts(
)
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(
client, auth_headers, settings, tmp_path
):
order = _seed_order(settings.orders_db_path)
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id,
payload={"candidate_score": 578, "guardian_notes": "to delete"},
submit=True,
)
attachment = _prepare_portal_attachment(settings, order.id)
report_path, pdf_path = _prepare_order_with_artifacts(settings, tmp_path, order.id)
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()
assert body["action"] == "deleted"
assert body["order_id"] == order.id
assert body["files_deleted"] == 2
assert body["files_deleted"] == 3
assert not report_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:
try:
@@ -170,3 +192,31 @@ def test_admin_anonymize_order_masks_pii_but_keeps_order(
assert deletion_service.audit_count(order.id) == 1
finally:
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 == {}

View File

@@ -201,3 +201,48 @@ def test_submit_requires_at_least_one_target_preference(client, settings):
)
assert resp.status_code == 422
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

View File

@@ -57,6 +57,7 @@ def test_alipay_notify_marks_order_paid_and_is_idempotent(tmp_path, monkeypatch)
"https://example.com/portal/payment-return",
)
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_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,
return_url=settings.payment_return_url,
app_id=settings.payment_app_id,
merchant_id=settings.payment_merchant_id,
private_key_path=settings.payment_private_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(
app_id=settings.payment_app_id,
merchant_id=settings.payment_merchant_id,
private_key_path=settings.payment_private_key_path,
alipay_public_key_path=settings.payment_alipay_public_key_path,
notify_url=settings.payment_notify_url,

View File

@@ -142,6 +142,34 @@ def test_payment_return_redirects_to_portal_status(client):
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(
tmp_path, monkeypatch
):

View File

@@ -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
@@ -128,27 +128,6 @@ class DeliveryNotificationService:
except sqlite3.IntegrityError:
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(
self,

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
@@ -52,7 +53,12 @@ class OrderDeletionService:
order = dao.get(order_id)
except OrderNotFound:
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)
if not deleted:
raise OrderNotFound(f"订单不存在: {order_id}")
@@ -76,6 +82,12 @@ class OrderDeletionService:
dao.get(order_id)
except OrderNotFound:
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()
self._conn.execute(
"""
@@ -118,11 +130,13 @@ class OrderDeletionService:
action="anonymize",
actor=actor,
reason=reason,
files_deleted=0,
files_deleted=files_deleted,
)
self._conn.commit()
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:
@@ -146,6 +160,61 @@ class OrderDeletionService:
(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:
row = self._conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",

View File

@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS payments (
provider TEXT NOT NULL,
amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0),
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,
checkout_token TEXT,
callback_payload TEXT,

View File

@@ -24,6 +24,7 @@ _PROVIDER_REQUIREMENTS: dict[str, list[str]] = {
"alipay_sim": [],
"alipay": [
"GAOKAO_PAYMENT_APP_ID",
"GAOKAO_PAYMENT_MERCHANT_ID",
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH",
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH",
"GAOKAO_PAYMENT_NOTIFY_URL",

View File

@@ -21,12 +21,14 @@ class AlipayProvider:
self,
*,
app_id: str,
merchant_id: str,
private_key_path: str,
alipay_public_key_path: str,
notify_url: str,
return_url: str,
) -> None:
self.app_id = app_id.strip()
self.merchant_id = merchant_id.strip()
self.notify_url = notify_url.strip()
self.return_url = return_url.strip()
self.private_key_path = Path(private_key_path)
@@ -88,6 +90,7 @@ class AlipayProvider:
) -> tuple[dict[str, Any], str]:
payload = {
"app_id": self.app_id,
"seller_id": self.merchant_id,
"notify_id": f"notify_{payment_id}",
"out_trade_no": payment_id,
"trade_no": provider_trade_no,
@@ -111,6 +114,7 @@ class AlipayProvider:
"status": str(payload.get("trade_status") or "TRADE_SUCCESS"),
"app_id": str(payload.get("app_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:

View File

@@ -30,6 +30,7 @@ def _build_provider(
notify_url: str = "",
return_url: str = "",
app_id: str = "",
merchant_id: str = "",
private_key_path: str = "",
alipay_public_key_path: str = "",
):
@@ -43,6 +44,7 @@ def _build_provider(
"alipay",
env={
"GAOKAO_PAYMENT_APP_ID": app_id,
"GAOKAO_PAYMENT_MERCHANT_ID": merchant_id,
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH": private_key_path,
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH": alipay_public_key_path,
"GAOKAO_PAYMENT_NOTIFY_URL": notify_url,
@@ -61,6 +63,7 @@ def _build_provider(
raise PaymentError("alipay provider not ready: " + "; ".join(details))
return AlipayProvider(
app_id=app_id,
merchant_id=merchant_id,
private_key_path=private_key_path,
alipay_public_key_path=alipay_public_key_path,
notify_url=notify_url,
@@ -85,6 +88,7 @@ class PaymentService:
notify_url: str = "",
return_url: str = "",
app_id: str = "",
merchant_id: str = "",
private_key_path: str = "",
alipay_public_key_path: str = "",
) -> None:
@@ -97,6 +101,7 @@ class PaymentService:
notify_url=notify_url,
return_url=return_url,
app_id=app_id,
merchant_id=merchant_id,
private_key_path=private_key_path,
alipay_public_key_path=alipay_public_key_path,
)
@@ -112,6 +117,7 @@ class PaymentService:
notify_url: str = "",
return_url: str = "",
app_id: str = "",
merchant_id: str = "",
private_key_path: str = "",
alipay_public_key_path: str = "",
) -> "PaymentService":
@@ -123,6 +129,7 @@ class PaymentService:
notify_url=notify_url,
return_url=return_url,
app_id=app_id,
merchant_id=merchant_id,
private_key_path=private_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 {
"pending",
"paid",
"refund_pending",
"refunded",
}:
return PaymentCheckout(
payment_id=existing.id,
@@ -198,16 +205,33 @@ class PaymentService:
if normalized_status not in success_statuses:
raise PaymentError("payment status not successful")
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(
normalized_payload.get("app_id") or payload.get("app_id") or ""
).strip()
received_notify_id = str(
normalized_payload.get("notify_id") or payload.get("notify_id") or ""
).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:
raise PaymentError("payment notify_id missing")
if expected_app_id and received_app_id and expected_app_id != received_app_id:
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(
normalized_payload.get("provider_trade_no") or ""
).strip()
@@ -269,19 +293,6 @@ class PaymentService:
return RefundRequestResult(
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":
raise PaymentError("payment is not refundable")
updated = payments.update_status(

View File

@@ -33,6 +33,7 @@ def _provider(tmp_path: Path) -> AlipayProvider:
private_path, public_path = _write_keypair(tmp_path)
return AlipayProvider(
app_id="20260001",
merchant_id="2088123412341234",
private_key_path=str(private_path),
alipay_public_key_path=str(public_path),
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",
"app_id": "20260001",
"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"

View File

@@ -23,7 +23,23 @@ def test_alipay_provider_report_detects_missing_credentials():
assert report.ready is False
assert "GAOKAO_PAYMENT_APP_ID" 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():
report = build_provider_readiness_report("unknown-provider", env={})

View File

@@ -38,7 +38,7 @@ def _mark_paid(settings, order: Order) -> PaymentService:
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)
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")
assert status_page.status_code == 200, 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

View File

@@ -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:
unchanged = dao.get(order.id)
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"

View File

@@ -1,3 +1,5 @@
# 开发 / 本地 smoke 模板;不是生产部署闭环定义。
# 生产上线以 docs/PRODUCTION_DEPLOYMENT_CHECKLIST_2026-06-15.md 和目标主机 systemd/cron/env 注入为准。
services:
gaokao-admin:
build:

View File

@@ -155,7 +155,7 @@ bash scripts/backup_verify.sh
- 异机 / 异地备份尚未落地
- 目标主机上的 cron / systemd timer 尚未实际安装验收
- 备份失败告警链(邮件 / IM / 监控)未接入
- 真实 SMTP / IM / 监控通道的目标机联调与失败演练记录仍未归档
- 密钥轮换与应急恢复仍缺真实执行记录
## 10. 不可降级的不变量
@@ -194,6 +194,7 @@ bash scripts/backup_verify.sh
- 接入 P1-8 整改的 service-level smoke (已完成)
- 增加异机异地备份 (P2-6 之后)
- 接入告警推送 (P1-8 后续)
- 备份失败告警链仍需目标主机接入真实 SMTP / IM / monitoring仓库内尚未提供可直接安装的 backup alert service
- 接入密钥轮换演练 (P2-6 之后)
- 真实演练记录归档 (例: `reports/DR_DRILL_2026-07-01.md`)
- 真实演练记录归档(模板:`reports/DR_DRILL_TEMPLATE.md`,示例:`reports/DR_DRILL_2026-07-01.md`
- 当前 restore smoke 证明的是“最小服务链可用”,不是完整生产级全链恢复

View File

@@ -51,10 +51,9 @@
- core = 100.00%
- 满足整体≥80%、核心≥100%
最新覆盖率口径已统一:
- 单一真相源:`scripts/check_coverage_gate.py`
- CI / dev-verify / codecov 全部指向同一阈值
- 本地与 CI 的硬门禁统一收敛到 `scripts/check_coverage_gate.py`
- `dev-verify.sh` 与 GitHub CI 都执行同一组硬阈值检查
- `codecov.yml` 当前保持同值展示口径,但不是执行单源,也不是阻断门禁
三仓同步状态:
@@ -137,19 +136,17 @@
完整清单见 `docs/P0_P1_P2_REMEDIATION_PLAN_2026-06-14.md`
- P2-1 公共下单孤儿订单
- P2-3 delivery `sent` 语义修正
- P1-3 真实支付回调校验
- P1-4 webhook DB 连接污染
- 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
- P1-8 备份恢复演练 / 目标机告警接入
- 隐私政策 / 服务协议 / 删除工单正式入口
已完成并不再列为整改项:
- P2-1 公共下单孤儿订单
- 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-03 Delivery 交付服务设计
- X-04 合规基线文档

View File

@@ -15,9 +15,9 @@
注意边界:
- 当前 `sent`表示站内交付物校验通过并推进事件状态”,**不是** 邮件/微信等外部渠道真实发送成功
- 当前 `station` 事件的最终状态是 `validated`表示站内交付物校验通过;只有 `email` 渠道真实发送后才进入 `delivered`
- dispatcher 每次会处理 `ready``failed` 事件;如果缺少 HTML/PDF会把事件记为 `failed`,并递增 `attempt_count`
- watchdog 复用同一 dispatch 路径,但只要本轮出现失败事件就返回 exit code `2`,适合接 systemd `OnFailure=` 或外部告警。
- watchdog 复用同一 dispatch 路径,但只要本轮出现失败事件就返回 exit code `2`,适合接宿主机监控或外部告警。
### retention cleanup
@@ -121,10 +121,7 @@ crontab -l
## 7. 仍未收口的缺口
这些仍然没有被本次 runbook 伪装成“已完成”:
1. `sent` 语义仍偏向“站内交付校验完成”,不是外部渠道真实投递成功
2. 尚无邮件/微信真实发送执行器。
3. watchdog 只有失败退出码,还没有仓库内置的告警推送集成。
4. retention cleanup 只是后台匿名化作业,前台/客服删除工单流程仍未上线。
5. 这些 unit/timer/cron 样例已落仓,但是否真正安装到目标生产主机,需要部署时另行执行并留存记录。
1. 尚无邮件/微信真实发送执行器。
2. watchdog 的本地告警 sink 已存在,但目标主机上的真实 SMTP / webhook 联调仍未验收。
3. retention cleanup 只是后台匿名化作业,前台/客服删除工单流程仍未上线
4. 这些 unit/timer/cron 样例已落仓,但是否真正安装到目标生产主机,需要部署时另行执行并留存记录。

View 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 演练、真实告警联调和正式外部文本签发。

View 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. 后续动作
- 待修复问题:
- 风险等级:
- 下一次演练时间:

View 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 failed710 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 自助支付/交付/灾备/合规全闭环”的系统。

View File

@@ -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_pdf": 200' 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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
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,
}
proc = subprocess.run(
["python3", "scripts/gaokao-delivery-dispatch.py", "--channel", "station"],
[sys.executable, "scripts/gaokao-delivery-dispatch.py", "--channel", "station"],
cwd=PROJECT_ROOT,
text=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,
}
proc = subprocess.run(
["python3", "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
[sys.executable, "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
cwd=PROJECT_ROOT,
text=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,
}
proc = subprocess.run(
["python3", "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
[sys.executable, "scripts/gaokao-delivery-watchdog.py", "--channel", "station"],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,

View File

@@ -129,7 +129,7 @@ def test_dao_delivered_transition_also_creates_notification_event(settings, tmp_
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")
_mark_paid(settings, order)
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.last_attempt_at is not None
notification_service.mark_sent(order.id)
sent = notification_service.list_events(order.id)[0]
assert sent.status == "sent"
assert sent.attempt_count == 2
assert sent.failure_reason is None
assert sent.last_attempt_at is not None
notification_service.mark_validated(order.id)
validated = notification_service.list_events(order.id)[0]
assert validated.status == "validated"
assert validated.attempt_count == 2
assert validated.failure_reason == "smtp timeout"
assert validated.last_attempt_at is not None
finally:
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")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
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}
proc = subprocess.run(
[
"python3",
sys.executable,
"scripts/gaokao-retention-cleanup.py",
"--cutoff",
"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}
proc = subprocess.run(
[
"python3",
sys.executable,
"scripts/gaokao-retention-cleanup.py",
"--retention-days",
"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}
proc = subprocess.run(
[
"python3",
sys.executable,
"scripts/gaokao_retention_cleanup.py",
"--cutoff",
"2025-06-30T00:00:00+00:00",

View File

@@ -1,5 +1,3 @@
"""T5.2 性能与并发测试。"""
from __future__ import annotations
import csv
@@ -129,6 +127,8 @@ def test_admin_locust_10_concurrency_success_rate_above_95(
) -> None:
report_prefix = tmp_path / "t5_2"
command = [
sys.executable,
"-m",
"locust",
"-f",
str(LOCUST_FILE),