diff --git a/admin/routes/web_public.py b/admin/routes/web_public.py index 6f43c92..39f477e 100644 --- a/admin/routes/web_public.py +++ b/admin/routes/web_public.py @@ -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"
  • {escape(str(item.get('original_name') or item.get('stored_name') or '未命名附件'))}" + f" ({escape(str(item.get('size_bytes') or '0'))} bytes)
  • " + ) + attachments_html = "".join(attachment_items) or "
  • 暂无附件
  • " return f""" 考生资料填写 @@ -756,6 +857,14 @@ def _render_info_page( +
    +

    已上传附件

    + +
    + + +
    +

    返回订单状态页

    
         
    @@ -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();
    +      }}
         
       
     
    diff --git a/admin/tests/conftest.py b/admin/tests/conftest.py
    index 96939a3..58c4dd0 100644
    --- a/admin/tests/conftest.py
    +++ b/admin/tests/conftest.py
    @@ -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")
    diff --git a/admin/tests/test_order_info_upload.py b/admin/tests/test_order_info_upload.py
    new file mode 100644
    index 0000000..674c0fa
    --- /dev/null
    +++ b/admin/tests/test_order_info_upload.py
    @@ -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
    diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md
    index dacec90..6a2b20d 100644
    --- a/docs/CURRENT_STATE.md
    +++ b/docs/CURRENT_STATE.md
    @@ -113,7 +113,7 @@
     
     - 用户端 Web 自助支付闭环缺失
     - 生产告警链与独立通知审计页仍未形成当前主系统标准能力(站内通知 + 邮件通知发送器已落地)
    -- 上传入口仍以后台 / CLI / 内部入口为主,用户前台入口不足
    +- 前台入口已支持基础资料填写与附件上传,但仍缺更完整的结构化资料向导与多文件策略
     - 业务数据备份 / 恢复 / 密钥托管已有本地基线与验证,但异机备份和生产接入仍不足
     - 隐私政策 / 服务协议 / 监护人同意 / 数据保留与删除流程仍缺前台/客服自助工单与正式法务版本
     
    diff --git a/requirements-admin.txt b/requirements-admin.txt
    index 1325b58..ea9b43c 100644
    --- a/requirements-admin.txt
    +++ b/requirements-admin.txt
    @@ -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