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