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 json
import logging import logging
import secrets
from html import escape from html import escape
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from urllib.parse import parse_qsl 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 ( from fastapi.responses import (
FileResponse, FileResponse,
HTMLResponse, HTMLResponse,
@@ -85,6 +86,13 @@ class PortalIntakeResponse(BaseModel):
order_id: str order_id: str
class PortalAttachmentUploaded(BaseModel):
order_id: str
intake_status: str
stage: str
attachment: dict[str, Any]
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
def landing_page() -> HTMLResponse: def landing_page() -> HTMLResponse:
return HTMLResponse(_render_landing_page()) return HTMLResponse(_render_landing_page())
@@ -244,17 +252,10 @@ def order_info_page(
) )
@router.post("/portal/{token}/info", response_model=PortalIntakeResponse) def _assert_portal_info_mutable(stage: str) -> None:
def submit_order_info( if stage == "pending_payment":
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":
raise HTTPException(status_code=409, detail="payment required before intake") raise HTTPException(status_code=409, detail="payment required before intake")
if context["stage"] in { if stage in {
"processing", "processing",
"report_ready", "report_ready",
"completed", "completed",
@@ -264,6 +265,96 @@ def submit_order_info(
raise HTTPException( raise HTTPException(
status_code=409, detail="intake is read-only at current stage" 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) intake_store = IntakeStore.for_db(settings.orders_db_path)
try: try:
record = intake_store.save( record = intake_store.save(
@@ -734,6 +825,16 @@ def _render_info_page(
privacy_checked = "checked" if payload.get("privacy_accepted") else "" privacy_checked = "checked" if payload.get("privacy_accepted") else ""
service_terms_checked = "checked" if payload.get("service_terms_accepted") else "" service_terms_checked = "checked" if payload.get("service_terms_accepted") else ""
guardian_checked = "checked" if payload.get("guardian_confirmed") 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> return f"""<!doctype html>
<html lang=\"zh-CN\"> <html lang=\"zh-CN\">
<head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>考生资料填写</title></head> <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('draft')\">保存草稿</button>
<button type=\"button\" onclick=\"submitIntake('submit')\">提交资料</button> <button type=\"button\" onclick=\"submitIntake('submit')\">提交资料</button>
</form> </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> <p><a href=\"/portal/{escape(token)}/status\">返回订单状态页</a></p>
<pre id=\"result\"></pre> <pre id=\"result\"></pre>
</main> </main>
@@ -785,6 +894,23 @@ def _render_info_page(
document.getElementById('result').textContent = JSON.stringify(body, null, 2); document.getElementById('result').textContent = JSON.stringify(body, null, 2);
if (resp.ok && mode === 'submit') window.location.href = '/portal/{escape(token)}/status'; 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> </script>
</body> </body>
</html> </html>

View File

@@ -38,11 +38,14 @@ def settings(tmp_path, secure_secret, monkeypatch):
orders_db_path = str(tmp_path / "orders.db") orders_db_path = str(tmp_path / "orders.db")
share_db_path = str(tmp_path / "short_links.db") share_db_path = str(tmp_path / "short_links.db")
share_report_dir = str(tmp_path / "share_reports") share_report_dir = str(tmp_path / "share_reports")
portal_upload_dir = str(tmp_path / "portal_uploads")
monkeypatch.setenv("GAOKAO_ENV", "dev") monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", db_path) monkeypatch.setenv("GAOKAO_DB_PATH", db_path)
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path) monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path)
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", share_db_path) monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", share_db_path)
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", share_report_dir) 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_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret) monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret)
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5") monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")

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

View File

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