diff --git a/inquiry-agent/agent/channel/h5/__init__.py b/inquiry-agent/agent/channel/h5/__init__.py index 5e8d1f0..cd06649 100644 --- a/inquiry-agent/agent/channel/h5/__init__.py +++ b/inquiry-agent/agent/channel/h5/__init__.py @@ -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]: """ diff --git a/inquiry-agent/agent/handlers/attachment_inquiry.py b/inquiry-agent/agent/handlers/attachment_inquiry.py index e265fa4..42bdbe4 100644 --- a/inquiry-agent/agent/handlers/attachment_inquiry.py +++ b/inquiry-agent/agent/handlers/attachment_inquiry.py @@ -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) diff --git a/inquiry-agent/agent/policy/attachment_wave.py b/inquiry-agent/agent/policy/attachment_wave.py index 481719d..5617094 100644 --- a/inquiry-agent/agent/policy/attachment_wave.py +++ b/inquiry-agent/agent/policy/attachment_wave.py @@ -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() diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index 15e2e4e..397faee 100644 --- a/inquiry-agent/agent/routing/dispatch.py +++ b/inquiry-agent/agent/routing/dispatch.py @@ -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) diff --git a/inquiry-agent/tests/test_attachment_inquiry.py b/inquiry-agent/tests/test_attachment_inquiry.py index 096e444..1b1598f 100644 --- a/inquiry-agent/tests/test_attachment_inquiry.py +++ b/inquiry-agent/tests/test_attachment_inquiry.py @@ -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") diff --git a/inquiry-agent/tests/test_attachment_upload_h5.py b/inquiry-agent/tests/test_attachment_upload_h5.py index 8d1e8cf..5d9d13c 100644 --- a/inquiry-agent/tests/test_attachment_upload_h5.py +++ b/inquiry-agent/tests/test_attachment_upload_h5.py @@ -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