feat(t12): add portal attachment upload for self-service intake
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
89
admin/tests/test_order_info_upload.py
Normal file
89
admin/tests/test_order_info_upload.py
Normal 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
|
||||
@@ -113,7 +113,7 @@
|
||||
|
||||
- 用户端 Web 自助支付闭环缺失
|
||||
- 生产告警链与独立通知审计页仍未形成当前主系统标准能力(站内通知 + 邮件通知发送器已落地)
|
||||
- 上传入口仍以后台 / CLI / 内部入口为主,用户前台入口不足
|
||||
- 前台入口已支持基础资料填写与附件上传,但仍缺更完整的结构化资料向导与多文件策略
|
||||
- 业务数据备份 / 恢复 / 密钥托管已有本地基线与验证,但异机备份和生产接入仍不足
|
||||
- 隐私政策 / 服务协议 / 监护人同意 / 数据保留与删除流程仍缺前台/客服自助工单与正式法务版本
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user