294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""
|
||
H5 补问 / 附件上传 / 复制工单号通道。
|
||
|
||
本文件职责:挂载 /inquiry-form;用 token 绑定身份或工单号(URL 禁止 userid)。
|
||
公网 Nginx 会把 /inquiry-form/upload/{token} 改写成 /inquiry-form/{token},
|
||
附件票必须在这两条路径上出同一张上传页、收同一套文件。
|
||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any, Optional
|
||
|
||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||
from fastapi.responses import HTMLResponse
|
||
from pydantic import BaseModel, Field
|
||
|
||
from agent.channel.clarification import validate_token
|
||
from agent.channel.h5.copy_page import expired_copy_page, render_copy_page
|
||
from agent.channel.h5.copy_store import lookup_copy_token
|
||
from agent.channel.h5.upload_page import (
|
||
expired_upload_page,
|
||
render_upload_message,
|
||
render_upload_page,
|
||
)
|
||
from agent.handlers.card_action import handle_card_action
|
||
from agent.policy import inquiry_copy as copy
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/inquiry-form", tags=["h5"])
|
||
|
||
|
||
class H5SubmitBody(BaseModel):
|
||
"""H5 提交体:业务字段放 fields;身份只认服务端 token 绑定。"""
|
||
|
||
action: str = Field(default="submit_clarify")
|
||
fields: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
@router.get("/ping")
|
||
def h5_ping() -> dict[str, str]:
|
||
"""存活探测。"""
|
||
return {"status": "ok", "channel": "h5"}
|
||
|
||
|
||
@router.get("/copy/{token}", response_class=HTMLResponse)
|
||
def h5_copy_ticket(token: str) -> HTMLResponse:
|
||
"""
|
||
复制工单号页:token 换工单号,页面写入剪贴板。
|
||
|
||
请求线程只读 token,不进 Graph、不发企微消息。
|
||
"""
|
||
wo = lookup_copy_token(token)
|
||
if not wo:
|
||
return HTMLResponse(expired_copy_page(), status_code=410)
|
||
return HTMLResponse(render_copy_page(wo))
|
||
|
||
|
||
@router.get("/jsapi-signature")
|
||
def h5_jsapi_signature(
|
||
url: str = Query(default=""),
|
||
kind: str = Query(default="corp"),
|
||
) -> dict[str, Any]:
|
||
"""
|
||
给复制页 ww.register 签名。失败给空对象,页面改走浏览器剪贴板。
|
||
"""
|
||
from agent.channel.wecom.jsapi import build_jsapi_signature
|
||
|
||
try:
|
||
return build_jsapi_signature(url=url, kind=kind)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.warning("jsapi 签名失败 kind=%s err=%s", kind, exc)
|
||
raise HTTPException(status_code=503, detail="jsapi_unavailable") from exc
|
||
|
||
|
||
def _is_upload_token(token: str) -> bool:
|
||
"""
|
||
附件上传票:allowed_actions 含 upload_attachment。
|
||
|
||
公网 Nginx 会把 /inquiry-form/upload/{token} 改写成 /inquiry-form/{token},
|
||
不能只认 /upload/ 这一条路径。
|
||
"""
|
||
rec = validate_token(token)
|
||
return rec is not None and "upload_attachment" in (rec.allowed_actions or [])
|
||
|
||
|
||
def _upload_page(token: str) -> HTMLResponse:
|
||
"""打开附件上传页。身份只认 token;过期给 410,不吐内部栈。"""
|
||
from agent.handlers.attachment_inquiry import lookup_upload_sender
|
||
|
||
if lookup_upload_sender(token) is None:
|
||
return HTMLResponse(expired_upload_page(), status_code=410)
|
||
return HTMLResponse(render_upload_page())
|
||
|
||
|
||
async def _accept_upload_files(
|
||
token: str,
|
||
files: Optional[list[UploadFile]],
|
||
) -> HTMLResponse:
|
||
"""
|
||
接收多个 Excel/PDF。请求线程只验 token、读字节、入队,不等千问。
|
||
"""
|
||
from agent.handlers.attachment_inquiry import accept_h5_uploads, lookup_upload_sender
|
||
|
||
sender_id = lookup_upload_sender(token)
|
||
if sender_id is None:
|
||
return HTMLResponse(expired_upload_page(), status_code=410)
|
||
incoming: list[tuple[str, bytes]] = []
|
||
for item in files or []:
|
||
name = item.filename or ""
|
||
raw = await item.read()
|
||
incoming.append((name, raw))
|
||
phase, hint = accept_h5_uploads(sender_id=sender_id, files=incoming, process_now=False)
|
||
logger.info("h5.upload phase=%s sender=%s n=%s", phase, sender_id, len(incoming))
|
||
if phase == "expired":
|
||
return HTMLResponse(expired_upload_page(), status_code=410)
|
||
if phase in {"reject_type", "need_file"}:
|
||
return HTMLResponse(render_upload_message("无法上传", hint), status_code=400)
|
||
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
|
||
|
||
|
||
@router.get("/upload/{token}", response_class=HTMLResponse)
|
||
def h5_upload_open(token: str) -> HTMLResponse:
|
||
"""显式上传路径。公网可能被 Nginx 改写到 /{token},两路必须同一页。"""
|
||
return _upload_page(token)
|
||
|
||
|
||
@router.post("/upload/{token}", response_class=HTMLResponse)
|
||
async def h5_upload_submit(
|
||
token: str,
|
||
files: Optional[list[UploadFile]] = File(default=None),
|
||
) -> HTMLResponse:
|
||
"""显式上传提交。与 POST /{token} 同一套入队逻辑。"""
|
||
return await _accept_upload_files(token, files)
|
||
|
||
|
||
@router.get("/air-routes/{token}", response_class=HTMLResponse)
|
||
def h5_air_route_open(token: str) -> HTMLResponse:
|
||
"""打开空运选线路页。身份只认 token。"""
|
||
from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_page
|
||
from agent.channel.h5.air_route_store import lookup_air_route_token
|
||
|
||
rec = lookup_air_route_token(token)
|
||
if rec is None:
|
||
return HTMLResponse(expired_air_route_page(), status_code=410)
|
||
return HTMLResponse(render_air_route_page(rec))
|
||
|
||
|
||
@router.post("/air-routes/{token}", response_class=HTMLResponse)
|
||
def h5_air_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse:
|
||
"""
|
||
选定线路。请求线程只验 token、入出站队列,不查 TMS、不跑整图。
|
||
"""
|
||
from agent.channel.h5.air_route_page import (
|
||
expired_air_route_page,
|
||
render_air_route_ok,
|
||
render_air_route_page,
|
||
)
|
||
from agent.channel.h5.air_route_store import lookup_air_route_token
|
||
from agent.channel.queue import get_message_store
|
||
from agent.policy.air_text_flow import get_air_text_flow
|
||
|
||
rec = lookup_air_route_token(token)
|
||
if rec is None:
|
||
return HTMLResponse(expired_air_route_page(), status_code=410)
|
||
sender_id = str(rec.get("sender_id") or "").strip()
|
||
if not sender_id:
|
||
return HTMLResponse(expired_air_route_page(), status_code=410)
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
store = get_message_store()
|
||
store.enqueue_outbound(touser=sender_id, content=text, payload=dict(extra or {}))
|
||
|
||
phase = get_air_text_flow().select_air_route(
|
||
sender_id=sender_id, index=int(index), reply=reply
|
||
)
|
||
logger.info("h5.air_route pick sender=%s phase=%s idx=%s", sender_id, phase, index)
|
||
if phase in {"route_expired", "route_invalid"}:
|
||
return HTMLResponse(render_air_route_page(rec), status_code=400)
|
||
return HTMLResponse(render_air_route_ok())
|
||
|
||
|
||
@router.get("/land-routes/{token}", response_class=HTMLResponse)
|
||
def h5_land_route_open(token: str) -> HTMLResponse:
|
||
"""打开陆运选线路页。身份只认 token。"""
|
||
from agent.channel.h5.land_route_page import expired_land_route_page, render_land_route_page
|
||
from agent.channel.h5.land_route_store import lookup_land_route_token
|
||
|
||
rec = lookup_land_route_token(token)
|
||
if rec is None:
|
||
return HTMLResponse(expired_land_route_page(), status_code=410)
|
||
return HTMLResponse(render_land_route_page(rec))
|
||
|
||
|
||
@router.post("/land-routes/{token}", response_class=HTMLResponse)
|
||
def h5_land_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse:
|
||
"""
|
||
选定陆运线路。请求线程只验 token、入出站队列,不查 TMS、不跑整图。
|
||
"""
|
||
from agent.channel.h5.land_route_page import (
|
||
expired_land_route_page,
|
||
render_land_route_ok,
|
||
render_land_route_page,
|
||
)
|
||
from agent.channel.h5.land_route_store import lookup_land_route_token
|
||
from agent.channel.queue import get_message_store
|
||
from agent.policy.land_text_flow import get_land_text_flow
|
||
|
||
rec = lookup_land_route_token(token)
|
||
if rec is None:
|
||
return HTMLResponse(expired_land_route_page(), status_code=410)
|
||
sender_id = str(rec.get("sender_id") or "").strip()
|
||
if not sender_id:
|
||
return HTMLResponse(expired_land_route_page(), status_code=410)
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
store = get_message_store()
|
||
store.enqueue_outbound(touser=sender_id, content=text, payload=dict(extra or {}))
|
||
|
||
phase = get_land_text_flow().select_land_route(
|
||
sender_id=sender_id, index=int(index), reply=reply
|
||
)
|
||
logger.info("h5.land_route pick sender=%s phase=%s idx=%s", sender_id, phase, index)
|
||
if phase in {"route_expired", "route_invalid"}:
|
||
return HTMLResponse(render_land_route_page(rec), status_code=400)
|
||
return HTMLResponse(render_land_route_ok())
|
||
|
||
|
||
@router.get("/{token}")
|
||
def h5_open(token: str):
|
||
"""
|
||
合同入口:/inquiry-form/{token}。
|
||
|
||
附件票出上传页(公网 Nginx 也会把 /upload/{token} 改写到这里)。
|
||
其它票仍回补问骨架,不吐内部栈。
|
||
"""
|
||
rec = validate_token(token)
|
||
if rec is None:
|
||
raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开")
|
||
if "upload_attachment" in (rec.allowed_actions or []):
|
||
logger.info("h5.open upload thread=%s", rec.thread_id)
|
||
return _upload_page(token)
|
||
return {
|
||
"status": "ok",
|
||
"thread_id": rec.thread_id,
|
||
"wait_version": rec.wait_version,
|
||
"allowed_actions": rec.allowed_actions,
|
||
"note": "骨架:字段列表后续按合同生成",
|
||
}
|
||
|
||
|
||
@router.post("/{token}", response_class=HTMLResponse)
|
||
async def h5_post_by_token(
|
||
token: str,
|
||
files: Optional[list[UploadFile]] = File(default=None),
|
||
) -> HTMLResponse:
|
||
"""
|
||
附件票在 /{token} 上收文件。
|
||
|
||
浏览器地址仍是 /upload/{token},表单相对提交后被 Nginx 改写到这里。
|
||
非附件票不收文件,避免冒充补问提交。
|
||
"""
|
||
if not _is_upload_token(token):
|
||
raise HTTPException(status_code=405, detail="该页不接收文件上传")
|
||
return await _accept_upload_files(token, files)
|
||
|
||
|
||
@router.post("/{token}/submit")
|
||
def h5_submit(token: str, body: H5SubmitBody) -> dict[str, Any]:
|
||
"""
|
||
提交补问:消费 token,转入 card_action 骨架(不跑整图)。
|
||
|
||
副作用:token 一次性;入队/Handler 后续接 Worker。
|
||
"""
|
||
rec = validate_token(token, action=body.action or "submit_clarify", consume=True)
|
||
if rec is None:
|
||
raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开")
|
||
handle_card_action(
|
||
sender_id=rec.sender_id,
|
||
thread_id=rec.thread_id,
|
||
wait_version=rec.wait_version,
|
||
action=body.action or "submit_clarify",
|
||
payload=body.fields,
|
||
)
|
||
logger.info(
|
||
"h5.submit ok thread=%s wait=%s action=%s",
|
||
rec.thread_id,
|
||
rec.wait_version,
|
||
body.action,
|
||
)
|
||
return {"status": "accepted", "thread_id": rec.thread_id}
|