落地空运单聊询价字段与线路选择。

必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 15:02:32 +08:00
co-authored by Cursor
parent 8afe909230
commit cfbce2fd69
53 changed files with 3470 additions and 372 deletions
+47 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
@@ -112,6 +112,52 @@ async def h5_upload_submit(
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
@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("/{token}")
def h5_open(token: str) -> dict[str, Any]:
"""
@@ -0,0 +1,282 @@
"""
空运选线路 H5 页。
本文件职责:列出 TMS 线路(航线+费用+总价),提交所选下标。
样式对齐附件询价页:企微内嵌、手机竖屏、卡片点选。
禁止:从 URL 读 userid;禁止本页查 TMS;禁止编造费用行。
"""
from __future__ import annotations
import html
from typing import Any
from agent.schema.air_route_options import (
option_fee_lines,
option_total,
parse_air_options,
route_codes,
route_line,
)
# 与 upload_page 同一套企微蓝、浅底、白卡片。禁止在页面写 userid。
_PAGE_CSS = """
:root {
--bg: #eef3f9;
--card: #ffffff;
--text: #17233d;
--muted: #5b6b7c;
--line: #c5d4ea;
--brand: #1677ff;
--brand-press: #0f62d9;
--ok: #0f8a5f;
--shadow: 0 10px 28px rgba(23, 51, 91, 0.10);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 480px;
margin: 0 auto;
padding: 16px 16px calc(96px + env(safe-area-inset-bottom));
}
.hero { margin: 4px 0 16px; }
.wo {
display: inline-block;
font-size: 12px;
color: #2b5aa8;
background: #eef4ff;
border-radius: 999px;
padding: 4px 10px;
margin: 0 0 8px;
word-break: break-all;
}
h1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; font-weight: 600; }
.lead { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.opt {
display: block;
position: relative;
background: var(--card);
border: 1.5px solid transparent;
border-radius: 16px;
padding: 16px 16px 14px;
margin: 0 0 12px;
box-shadow: var(--shadow);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.opt:has(input:checked), .opt.is-on {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16), var(--shadow);
}
.opt input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.head {
display: flex;
align-items: flex-start;
gap: 12px;
}
.tick {
width: 22px;
height: 22px;
margin-top: 2px;
border: 2px solid var(--line);
border-radius: 50%;
flex-shrink: 0;
background: #fff;
}
.opt:has(input:checked) .tick, .opt.is-on .tick {
border-color: var(--brand);
background: var(--brand);
box-shadow: inset 0 0 0 4px #fff;
}
.route { flex: 1; min-width: 0; }
.route strong {
display: block;
font-size: 17px;
line-height: 1.4;
font-weight: 650;
}
.codes { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 0.04em; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.pill {
font-size: 12px;
color: #2b5aa8;
background: #eef4ff;
border-radius: 999px;
padding: 3px 8px;
}
.pill.via { color: #8a5a00; background: #fff6e0; }
.fees { list-style: none; margin: 12px 0 0; padding: 0; }
.fees li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-top: 1px solid #eef2f6;
font-size: 14px;
line-height: 1.5;
}
.fees .n { color: var(--muted); }
.fees .a { font-variant-numeric: tabular-nums; text-align: right; }
.empty {
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid #eef2f6;
color: var(--muted);
font-size: 13px;
}
.total {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px dashed var(--line);
font-size: 15px;
}
.total b { color: var(--brand); font-size: 18px; font-variant-numeric: tabular-nums; }
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: rgba(255,255,255,0.96);
box-shadow: 0 -8px 24px rgba(23, 51, 91, 0.08);
}
.bar-inner { max-width: 480px; margin: 0 auto; }
button[type="submit"] {
width: 100%;
min-height: 48px;
border: 0;
border-radius: 10px;
background: var(--brand);
color: #fff;
font-size: 16px;
font-weight: 700;
cursor: pointer;
touch-action: manipulation;
}
button[type="submit"]:active { background: var(--brand-press); }
.msg {
max-width: 480px;
margin: 48px auto;
padding: 0 16px;
text-align: center;
}
.msg h1 { font-size: 20px; margin: 0 0 8px; }
.msg p { margin: 0; color: var(--muted); font-size: 15px; line-height: 1.6; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
"""
def expired_air_route_page() -> str:
"""token 过期页。"""
return _wrap(
"已过期",
"<div class='msg'><h1>链接已过期</h1><p>请回企微重新打开选线路卡片。</p></div>",
)
def render_air_route_page(payload: dict[str, Any]) -> str:
"""
渲染选线路页。payload 来自 token:quote / facts / work_order_no。
"""
quote = dict(payload.get("quote") or {})
facts = dict(payload.get("facts") or {})
wo = html.escape(str(payload.get("work_order_no") or ""))
options = parse_air_options(quote)
rows: list[str] = []
for i, option in enumerate(options):
rows.append(_option_card(i, option, quote, facts))
count = len(options)
body = (
"<div class='wrap'>"
"<header class='hero'>"
f"<p class='wo'>{wo}</p>"
"<h1>选择线路</h1>"
f"<p class='lead'>共 {count} 条线路,点选一条后确定。费用以 TMS 回包为准。</p>"
"</header>"
"<form method='post'>"
+ "".join(rows)
+ "<div class='bar'><div class='bar-inner'>"
"<button type='submit'>选定这条</button>"
"</div></div></form></div>"
"<script>(function(){var f=document.querySelector('form');if(!f)return;"
"function sync(){f.querySelectorAll('.opt').forEach(function(el){"
"el.classList.toggle('is-on',!!(el.querySelector('input')||{}).checked);});}"
"f.addEventListener('change',sync);})();</script>"
)
return _wrap("选择线路", body)
def render_air_route_ok() -> str:
"""选定成功。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
)
def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str:
"""一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。"""
src = dict(option or {})
line = html.escape(route_line(src, facts))
codes = html.escape(route_codes(src, facts, quote))
fees = option_fee_lines(src, quote)
if fees:
fee_html = (
"<ul class='fees'>"
+ "".join(
f"<li><span class='n'>{html.escape(name)}</span>"
f"<span class='a'>{html.escape(amount)}</span></li>"
for name, amount in fees
)
+ "</ul>"
)
else:
fee_html = "<p class='empty'>本线路未返回费用明细</p>"
total = html.escape(option_total(src, quote))
transfer = str(src.get("transferAirportCode") or src.get("transfer") or "").strip()
tag = "中转" if transfer else "直达"
tag_cls = "pill via" if transfer else "pill"
flight = html.escape(str(src.get("flightNo") or "").strip())
pills = [f"<span class='{tag_cls}'>{tag}</span>"]
if flight:
pills.append(f"<span class='pill'>航班 {flight}</span>")
codes_html = f"<span class='codes'>{codes}</span>" if codes else ""
return (
"<label class='opt'>"
f"<input type='radio' name='index' value='{index}' required />"
"<div class='head'><span class='tick' aria-hidden='true'></span>"
f"<div class='route'><strong>{line}</strong>{codes_html}"
f"<div class='meta'>{''.join(pills)}</div></div></div>"
f"{fee_html}"
f"<p class='total'><span>总销售价</span><b>{total}</b></p>"
"</label>"
)
def _wrap(title: str, body: str) -> str:
"""整页壳:标题、viewport、共用样式。"""
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{html.escape(title)}</title>"
f"<style>{_PAGE_CSS}</style></head><body>"
f"{body}</body></html>"
)
@@ -0,0 +1,121 @@
"""
空运选线路 H5 的短命 token。
本文件职责:token 绑定 sender + 工单 + 线路报价;URL 禁止 userid。
优先 Redis;单测走内存。
禁止:在本包查 TMS、改六态。
"""
from __future__ import annotations
import json
import logging
import secrets
import threading
import time
from typing import Any, Optional
from agent.channel.h5.copy_store import inquiry_form_base
from agent.config import get_settings
from agent.redis_coord.keys import TOKEN_COPY_TICKET
logger = logging.getLogger(__name__)
AIR_ROUTE_TTL_SECONDS = 7 * 24 * 60 * 60
_KEY = TOKEN_COPY_TICKET + ":air_route"
_MEM: dict[str, tuple[str, float]] = {}
_MEM_LOCK = threading.Lock()
def issue_air_route_token(
*,
sender_id: str,
work_order_no: str,
quote: dict[str, Any],
facts: dict[str, str] | None = None,
ttl_seconds: int = AIR_ROUTE_TTL_SECONDS,
) -> str:
"""签发选线路 token。副作用:写 Redis 或内存。"""
payload = json.dumps(
{
"sender_id": sender_id,
"work_order_no": work_order_no,
"quote": quote,
"facts": dict(facts or {}),
},
ensure_ascii=False,
)
token = secrets.token_urlsafe(24)
if _redis_set(token, payload, ttl_seconds):
return token
expires = time.time() + ttl_seconds
with _MEM_LOCK:
_MEM[token] = (payload, expires)
logger.info("air route token 走内存 wo=%s", work_order_no)
return token
def lookup_air_route_token(token: str) -> Optional[dict[str, Any]]:
"""读 token。过期或不存在返回 None。"""
raw = (token or "").strip()
if not raw:
return None
text = _redis_get(raw)
if text is None:
with _MEM_LOCK:
hit = _MEM.get(raw)
if not hit:
return None
payload, expires = hit
if expires < time.time():
_MEM.pop(raw, None)
return None
text = payload
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def build_air_route_url(token: str) -> str:
"""拼选线路页:{inquiry-form}/air-routes/{token}。"""
raw = (token or "").strip()
if not raw:
return ""
return f"{inquiry_form_base().rstrip('/')}/air-routes/{raw}"
def _redis_set(token: str, payload: str, ttl: int) -> bool:
client = _try_redis()
if client is None:
return False
try:
client.raw.set(client.key(_KEY, token), payload, ex=ttl)
return True
except Exception as exc: # noqa: BLE001
logger.warning("air route token Redis 写入失败:%s", exc)
return False
def _redis_get(token: str) -> Optional[str]:
client = _try_redis()
if client is None:
return None
try:
raw = client.raw.get(client.key(_KEY, token))
except Exception as exc: # noqa: BLE001
logger.warning("air route token Redis 读取失败:%s", exc)
return None
if raw is None:
return None
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
def _try_redis():
try:
from agent.redis_coord import get_redis_runtime
return get_redis_runtime(get_settings()).client
except Exception: # noqa: BLE001
return None