修复附件询价:公网打开上传页不再出骨架 JSON,未上传时打字能正常回话。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 18:54:41 +08:00
co-authored by Cursor
parent 49faa35f47
commit 8e73af0d03
6 changed files with 150 additions and 15 deletions
+53 -9
View File
@@ -2,6 +2,8 @@
H5 补问 / 附件上传 / 复制工单号通道。
本文件职责:挂载 /inquiry-form;用 token 绑定身份或工单号(URL 禁止 userid)。
公网 Nginx 会把 /inquiry-form/upload/{token} 改写成 /inquiry-form/{token},
附件票必须在这两条路径上出同一张上传页、收同一套文件。
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
"""
@@ -73,11 +75,19 @@ def h5_jsapi_signature(
raise HTTPException(status_code=503, detail="jsapi_unavailable") from exc
@router.get("/upload/{token}", response_class=HTMLResponse)
def h5_upload_open(token: str) -> HTMLResponse:
def _is_upload_token(token: str) -> bool:
"""
打开附件上传页。身份只认 token;过期给 410,不吐内部栈。
附件上传票: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:
@@ -85,10 +95,9 @@ def h5_upload_open(token: str) -> HTMLResponse:
return HTMLResponse(render_upload_page())
@router.post("/upload/{token}", response_class=HTMLResponse)
async def h5_upload_submit(
async def _accept_upload_files(
token: str,
files: Optional[list[UploadFile]] = File(default=None),
files: Optional[list[UploadFile]],
) -> HTMLResponse:
"""
接收多个 Excel/PDF。请求线程只验 token、读字节、入队,不等千问。
@@ -112,6 +121,21 @@ async def h5_upload_submit(
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。"""
@@ -205,15 +229,19 @@ def h5_land_route_pick(token: str, index: int = Form(default=-1)) -> HTMLRespons
@router.get("/{token}")
def h5_open(token: str) -> dict[str, Any]:
def h5_open(token: str):
"""
打开补问页:校验 token 未过期。
合同入口:/inquiry-form/{token}。
返回允许动作与 wait_version;不返回内部异常栈。
附件票出上传页(公网 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,
@@ -223,6 +251,22 @@ def h5_open(token: str) -> dict[str, Any]:
}
@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]:
"""
@@ -79,6 +79,21 @@ def is_active_attachment_wait(sender_id: str, *, now: Optional[float] = None) ->
return get_attach_wave_book().active(sender_id, now=now) is not None
def is_idle_attachment_wait(sender_id: str, *, now: Optional[float] = None) -> bool:
"""已发卡但还没传 Excel/PDF/图。此时销售打字应走文字询价。"""
wave = get_attach_wave_book().active(sender_id, now=now)
return wave is not None and not wave.started
def abandon_idle_attach_wait(sender_id: str) -> bool:
"""
作废未开始认的等待票。已开始认返回 False。
副作用:旧上传卡随后按过期拒绝。
"""
return get_attach_wave_book().abandon_if_idle(sender_id)
def append_attach_text(sender_id: str, text: str) -> bool:
"""认完之前的字并进本票,不走文字询价。"""
return get_attach_wave_book().append_follow_text(sender_id, text)
@@ -287,6 +287,24 @@ class AttachWaveBook:
parts.append(wave.follow_text.strip())
return "\n".join(parts)
def abandon_if_idle(self, sender_id: str) -> bool:
"""
还没开始认材料:作废等待票。已开始认或无票返回 False。
销售发卡后改口打字询价时用。旧 H5 随后按过期拒绝。
"""
sid = (sender_id or "").strip()
if not sid:
return False
with self._lock:
self._hydrate(sid)
wave = self._waves.get(sid)
if wave is None or wave.business_done or wave.started:
return False
wave.business_done = True
self._persist(sid)
return True
def mark_business_done(self, sender_id: str) -> None:
"""业务补问/卡片已发出。之后旧 H5 与并入图都必须拒绝。"""
sid = (sender_id or "").strip()
+17 -4
View File
@@ -262,16 +262,29 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
handle_image_inquiry(message)
return "image_inquiry"
if kind in {"text", ""}:
from agent.handlers.attachment_inquiry import append_attach_text, is_active_attachment_wait
from agent.handlers.attachment_inquiry import (
abandon_idle_attach_wait,
append_attach_text,
is_active_attachment_wait,
is_idle_attachment_wait,
)
from agent.handlers.image_inquiry import append_wave_text, is_active_image_wave
if is_active_attachment_wait(message.sender_id) and (message.content or "").strip():
if decision.intent == "attachment_inquiry":
handle_attachment_inquiry(message)
return "attachment_inquiry"
append_attach_text(message.sender_id, message.content or "")
logger.info("dispatch 附件波次收字 sender=%s", message.sender_id)
return "attach_wave_follow_text"
if is_idle_attachment_wait(message.sender_id):
abandon_idle_attach_wait(message.sender_id)
logger.info(
"dispatch 附件未传材料,改走文字 sender=%s intent=%s",
message.sender_id,
decision.intent,
)
else:
append_attach_text(message.sender_id, message.content or "")
logger.info("dispatch 附件波次收字 sender=%s", message.sender_id)
return "attach_wave_follow_text"
if is_active_image_wave(message.sender_id) and (message.content or "").strip():
append_wave_text(message.sender_id, message.content or "")
logger.info("dispatch 图片波次收字 sender=%s", message.sender_id)
+23 -2
View File
@@ -239,13 +239,34 @@ class AttachmentInquiryTests(unittest.TestCase):
)
self.assertEqual(name, "attachment_inquiry")
def test_follow_text_not_create_ticket_before_upload(self) -> None:
def test_text_inquiry_before_upload_gets_reply(self) -> None:
"""发卡后还没传材料:销售改口打字询价必须有回话,不能默收。"""
handle_attachment_inquiry(
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
store=self.store,
)
name = dispatch_inbound(
InboundMessage(sender_id="s1", message_id="m2", content="海运上海到纽约 2个40尺"),
InboundMessage(sender_id="s1", message_id="m2", content=SEA_ORAL),
RouteDecision(intent="ordinary_text_inquiry"),
)
self.assertEqual(name, "ordinary_text_inquiry")
self.assertIsNone(get_attach_wave_book().active("s1"))
self.assertIsNotNone(get_sea_text_flow().session_of("s1"))
def test_follow_text_while_recognizing_not_new_ticket(self) -> None:
"""已经开始认附件:期间的字并进口播,不另开文字询价。"""
handle_attachment_inquiry(
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
store=self.store,
)
accept_h5_uploads(
sender_id="s1",
files=[("sea.xlsx", b"fake")],
store=self.store,
process_now=False,
)
name = dispatch_inbound(
InboundMessage(sender_id="s1", message_id="m2", content="只要整柜"),
RouteDecision(intent="ordinary_text_inquiry"),
)
self.assertEqual(name, "attach_wave_follow_text")
@@ -66,6 +66,30 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertEqual(miss.status_code, 410)
self.assertIn("过期", miss.text)
def test_bare_token_opens_upload_page_not_json(self) -> None:
"""
公网 Nginx 会把 /inquiry-form/upload/{token} 改写成 /inquiry-form/{token}。
附件票必须出上传页,不能把补问骨架 JSON 直接给销售。
"""
token = self._open_card()
hit = self.client.get(f"/inquiry-form/{token}")
self.assertEqual(hit.status_code, 200)
self.assertIn("text/html", hit.headers.get("content-type", ""))
self.assertIn("Excel", hit.text)
self.assertNotIn("骨架", hit.text)
self.assertNotIn("s1", hit.text)
miss = self.client.get("/inquiry-form/not-a-real-token")
self.assertIn(miss.status_code, (410, 422))
def test_bare_token_post_png_rejected(self) -> None:
token = self._open_card()
hit = self.client.post(
f"/inquiry-form/{token}",
files=[("files", ("shot.png", b"\x89PNG", "image/png"))],
)
self.assertEqual(hit.status_code, 400)
self.assertIn("Excel", hit.text)
def test_upload_page_uses_card_chrome(self) -> None:
from agent.channel.h5.upload_page import expired_upload_page, render_upload_message, render_upload_page