Files
inquiry_robot/inquiry-agent/agent/channel/h5/__init__.py
T

294 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}