feat(t12): add portal attachment upload for self-service intake
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 16:26:26 +08:00
parent 4215e8fb64
commit b965dc2863
5 changed files with 231 additions and 12 deletions

View File

@@ -4,12 +4,13 @@ from __future__ import annotations
import json
import logging
import secrets
from html import escape
from pathlib import Path
from typing import Any, Literal
from urllib.parse import parse_qsl
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import (
FileResponse,
HTMLResponse,
@@ -85,6 +86,13 @@ class PortalIntakeResponse(BaseModel):
order_id: str
class PortalAttachmentUploaded(BaseModel):
order_id: str
intake_status: str
stage: str
attachment: dict[str, Any]
@router.get("/", include_in_schema=False)
def landing_page() -> HTMLResponse:
return HTMLResponse(_render_landing_page())
@@ -244,17 +252,10 @@ def order_info_page(
)
@router.post("/portal/{token}/info", response_model=PortalIntakeResponse)
def submit_order_info(
token: str,
payload: IntakePayload,
settings: Settings = Depends(get_settings_dep),
) -> PortalIntakeResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
if context["stage"] == "pending_payment":
def _assert_portal_info_mutable(stage: str) -> None:
if stage == "pending_payment":
raise HTTPException(status_code=409, detail="payment required before intake")
if context["stage"] in {
if stage in {
"processing",
"report_ready",
"completed",
@@ -264,6 +265,96 @@ def submit_order_info(
raise HTTPException(
status_code=409, detail="intake is read-only at current stage"
)
def _sanitize_upload_filename(name: str) -> str:
raw = Path(name or "upload.bin").name
return raw.replace("/", "_").replace("\\", "_")
def _validate_upload(file_name: str, content_type: str | None, payload: bytes, settings: Settings) -> None:
suffix = Path(file_name).suffix.lower()
allowed_suffixes = {".pdf", ".txt", ".md", ".json", ".png", ".jpg", ".jpeg", ".webp"}
if suffix not in allowed_suffixes:
raise HTTPException(status_code=415, detail=f"unsupported attachment type: {suffix or 'unknown'}")
if len(payload) > settings.portal_upload_max_bytes:
raise HTTPException(status_code=413, detail="attachment too large")
if not payload:
raise HTTPException(status_code=400, detail="empty attachment")
def _store_portal_attachment(
*, order_id: str, upload_name: str, content_type: str | None, payload: bytes, settings: Settings
) -> dict[str, Any]:
safe_name = _sanitize_upload_filename(upload_name)
_validate_upload(safe_name, content_type, payload, settings)
upload_dir = Path(settings.portal_upload_dir) / order_id
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{secrets.token_hex(8)}-{safe_name}"
target = upload_dir / stored_name
target.write_bytes(payload)
return {
"original_name": safe_name,
"stored_name": stored_name,
"content_type": content_type or "application/octet-stream",
"size_bytes": len(payload),
"storage_path": str(target),
"kind": "portal_attachment",
}
@router.post("/portal/{token}/attachments", response_model=PortalAttachmentUploaded)
def upload_order_attachment(
token: str,
file: UploadFile = File(...),
settings: Settings = Depends(get_settings_dep),
) -> PortalAttachmentUploaded:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
_assert_portal_info_mutable(context["stage"])
raw = file.file.read()
attachment = _store_portal_attachment(
order_id=order.id,
upload_name=file.filename or "upload.bin",
content_type=file.content_type,
payload=raw,
settings=settings,
)
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
current = intake_store.get(order.id)
payload = dict(current.payload) if current is not None else {}
attachments = list(payload.get("attachments") or [])
attachments.append(attachment)
payload["attachments"] = attachments
record = intake_store.save(
order_id=order.id,
payload=payload,
submit=(current.status == "submitted") if current is not None else False,
)
finally:
intake_store.close()
refreshed_order = _resolve_order_from_token(token, settings)
refreshed_context = _build_portal_context(refreshed_order, settings)
return PortalAttachmentUploaded(
order_id=order.id,
intake_status=record.status,
stage=refreshed_context["stage"],
attachment=attachment,
)
@router.post("/portal/{token}/info", response_model=PortalIntakeResponse)
def submit_order_info(
token: str,
payload: IntakePayload,
settings: Settings = Depends(get_settings_dep),
) -> PortalIntakeResponse:
order = _resolve_order_from_token(token, settings)
context = _build_portal_context(order, settings)
_assert_portal_info_mutable(context["stage"])
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.save(
@@ -734,6 +825,16 @@ def _render_info_page(
privacy_checked = "checked" if payload.get("privacy_accepted") else ""
service_terms_checked = "checked" if payload.get("service_terms_accepted") else ""
guardian_checked = "checked" if payload.get("guardian_confirmed") else ""
attachments = payload.get("attachments") or []
attachment_items = []
for item in attachments:
if not isinstance(item, dict):
continue
attachment_items.append(
f"<li>{escape(str(item.get('original_name') or item.get('stored_name') or '未命名附件'))}"
f" ({escape(str(item.get('size_bytes') or '0'))} bytes)</li>"
)
attachments_html = "".join(attachment_items) or "<li>暂无附件</li>"
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></head>
@@ -756,6 +857,14 @@ def _render_info_page(
<button type=\"button\" onclick=\"submitIntake('draft')\">保存草稿</button>
<button type=\"button\" onclick=\"submitIntake('submit')\">提交资料</button>
</form>
<section style=\"margin-top:16px;padding:12px;border:1px solid #dbe3f0;border-radius:12px;\">
<h2>已上传附件</h2>
<ul>{attachments_html}</ul>
<form id=\"attachment-form\">
<input type=\"file\" name=\"file\" />
<button type=\"button\" onclick=\"uploadAttachment()\">上传 AI 方案 / 资料附件</button>
</form>
</section>
<p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></p>
<pre id=\"result\"></pre>
</main>
@@ -785,6 +894,23 @@ def _render_info_page(
document.getElementById('result').textContent = JSON.stringify(body, null, 2);
if (resp.ok && mode === 'submit') window.location.href = '/portal/{escape(token)}/status';
}}
async function uploadAttachment() {{
const form = document.getElementById('attachment-form');
const data = new FormData(form);
const file = data.get('file');
if (!file || !(file instanceof File) || !file.name) {{
document.getElementById('result').textContent = '请先选择一个附件';
return;
}}
const resp = await fetch('/portal/{escape(token)}/attachments', {{
method: 'POST',
body: data,
}});
const body = await resp.json();
document.getElementById('result').textContent = JSON.stringify(body, null, 2);
if (resp.ok) window.location.reload();
}}
</script>
</body>
</html>

View File

@@ -38,11 +38,14 @@ def settings(tmp_path, secure_secret, monkeypatch):
orders_db_path = str(tmp_path / "orders.db")
share_db_path = str(tmp_path / "short_links.db")
share_report_dir = str(tmp_path / "share_reports")
portal_upload_dir = str(tmp_path / "portal_uploads")
monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", db_path)
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path)
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", share_db_path)
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", share_report_dir)
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_DIR", portal_upload_dir)
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_MAX_BYTES", "5242880")
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")

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from pathlib import Path
from data.customer_portal.token import issue_portal_token
from data.orders.dao import OrdersDAO
from data.orders.models import Order
from data.payments.service import PaymentService
def _seed_order(db_path: str, order_id: str = "GKO-20260615-UPLOAD") -> Order:
order = Order(
id=order_id,
source="web",
service_version="audit",
amount_cents=4900,
status="pending",
customer_name="张家长",
customer_phone="13800138000",
candidate_name="张三",
candidate_province="湖南",
)
with OrdersDAO.connect(db_path) as dao:
return dao.create(order, actor="test", reason="seed")
def _mark_paid(settings, order: Order) -> None:
service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
)
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=f"MOCK-{order.id}",
)
handled = service.handle_webhook(payload, headers["X-Mock-Signature"])
assert handled.order_status == "paid"
def test_portal_attachment_upload_persists_metadata_and_file(client, settings):
order = _seed_order(settings.orders_db_path)
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
upload = client.post(
f"/portal/{token}/attachments",
files={"file": ("qianwen-plan.txt", "高校A\n专业B\n建议".encode("utf-8"), "text/plain")},
)
assert upload.status_code == 200, upload.text
body = upload.json()
assert body["order_id"] == order.id
assert body["stage"] == "info_required"
meta = body["attachment"]
assert meta["original_name"] == "qianwen-plan.txt"
assert meta["size_bytes"] > 0
assert Path(meta["storage_path"]).is_file()
page = client.get(f"/portal/{token}/info")
assert page.status_code == 200, page.text
assert "已上传附件" in page.text
assert "qianwen-plan.txt" in page.text
def test_portal_attachment_upload_rejects_before_payment(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-UPLOAD-BLOCK")
token = issue_portal_token(order.id, settings.portal_token_secret)
upload = client.post(
f"/portal/{token}/attachments",
files={"file": ("plan.txt", b"draft", "text/plain")},
)
assert upload.status_code == 409
assert "payment required" in upload.text
def test_portal_attachment_upload_rejects_unsupported_type(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-UPLOAD-TYPE")
_mark_paid(settings, order)
token = issue_portal_token(order.id, settings.portal_token_secret)
upload = client.post(
f"/portal/{token}/attachments",
files={"file": ("plan.exe", b"MZ...", "application/octet-stream")},
)
assert upload.status_code == 415
assert "unsupported attachment type" in upload.text

View File

@@ -113,7 +113,7 @@
- 用户端 Web 自助支付闭环缺失
- 生产告警链与独立通知审计页仍未形成当前主系统标准能力(站内通知 + 邮件通知发送器已落地)
- 上传入口仍以后台 / CLI / 内部入口为主,用户前台入口不足
- 前台入口已支持基础资料填写与附件上传,但仍缺更完整的结构化资料向导与多文件策略
- 业务数据备份 / 恢复 / 密钥托管已有本地基线与验证,但异机备份和生产接入仍不足
- 隐私政策 / 服务协议 / 监护人同意 / 数据保留与删除流程仍缺前台/客服自助工单与正式法务版本

View File

@@ -9,3 +9,4 @@ cryptography>=43,<46
Jinja2>=3.1,<4.0
weasyprint>=66,<67
cairocffi>=1.7,<2.0
python-multipart>=0.0.20,<1.0