让成交卡等报价文件真正发出后再出现,并收紧 PDF 顶部留白,避免抬头被裁或空一截。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
H5 补问 / 附件上传通道。
|
||||
H5 补问 / 附件上传 / 复制工单号通道。
|
||||
|
||||
本文件职责:挂载 /inquiry-form;用 clarification token 绑定身份(URL 禁止 userid)。
|
||||
本文件职责:挂载 /inquiry-form;用 token 绑定身份或工单号(URL 禁止 userid)。
|
||||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
|
||||
"""
|
||||
|
||||
@@ -10,10 +10,13 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
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.handlers.card_action import handle_card_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,6 +37,36 @@ 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
|
||||
|
||||
|
||||
@router.get("/{token}")
|
||||
def h5_open(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
复制工单号页 HTML。
|
||||
|
||||
本文件职责:打开后立刻把工单号写入系统剪贴板(企微 JS-SDK + 浏览器兜底)。
|
||||
禁止:URL/页面带 userid;禁止在本页调主账或改六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from agent.config import get_settings
|
||||
|
||||
_EXPIRED_HTML = """<!doctype html>
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>链接已过期</title>
|
||||
</head><body style="font-family:sans-serif;padding:32px;text-align:center;color:#333">
|
||||
<p>复制链接已过期。</p>
|
||||
<p>请回到企微,重新询价后再点「复制工单号」。</p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def expired_copy_page() -> str:
|
||||
"""token 无效时给销售看的短页,不暴露内部原因。"""
|
||||
return _EXPIRED_HTML
|
||||
|
||||
|
||||
def render_copy_page(work_order_no: str) -> str:
|
||||
"""
|
||||
渲染复制页。工单号只进页面正文,由 JS 写入剪贴板。
|
||||
|
||||
先走企微 ww.setClipboardData(应用内才能真复制),失败再走浏览器剪贴板。
|
||||
"""
|
||||
wo = html.escape((work_order_no or "").strip(), quote=True)
|
||||
settings = get_settings()
|
||||
corp = html.escape((settings.wecom_corp_id or "").strip(), quote=True)
|
||||
agent = html.escape(str(settings.wecom_agent_id or "1000010").strip(), quote=True)
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>复制工单号</title>
|
||||
<style>
|
||||
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
background:#f5f6f7; color:#1f2329; }}
|
||||
.wrap {{ max-width:420px; margin:48px auto; padding:0 20px; text-align:center; }}
|
||||
.wo {{ font-size:22px; font-weight:600; letter-spacing:.04em; margin:16px 0 8px; word-break:break-all; }}
|
||||
.st {{ min-height:24px; color:#646a73; font-size:14px; }}
|
||||
.ok {{ color:#00a870; }}
|
||||
.bad {{ color:#d83931; }}
|
||||
button {{ margin-top:20px; width:100%; height:44px; border:0; border-radius:8px;
|
||||
background:#1677ff; color:#fff; font-size:16px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div>工单号</div>
|
||||
<div class="wo" id="wo" data-wo="{wo}">{wo}</div>
|
||||
<div class="st" id="st">正在复制…</div>
|
||||
<button type="button" id="btn">复制工单号</button>
|
||||
</div>
|
||||
<script src="https://wwcdn.weixin.qq.com/node/wework/open/js/wecom-jssdk-2.3.2.js"></script>
|
||||
<script>
|
||||
(function () {{
|
||||
var text = document.getElementById("wo").getAttribute("data-wo") || "";
|
||||
var st = document.getElementById("st");
|
||||
var corpId = "{corp}";
|
||||
var agentId = Number("{agent}") || 0;
|
||||
|
||||
function show(ok, msg) {{
|
||||
st.className = "st " + (ok ? "ok" : "bad");
|
||||
st.textContent = msg;
|
||||
}}
|
||||
|
||||
function browserCopy(value) {{
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {{
|
||||
return navigator.clipboard.writeText(value);
|
||||
}}
|
||||
return new Promise(function (resolve, reject) {{
|
||||
var box = document.createElement("textarea");
|
||||
box.value = value;
|
||||
box.setAttribute("readonly", "readonly");
|
||||
box.style.position = "fixed";
|
||||
box.style.left = "-9999px";
|
||||
document.body.appendChild(box);
|
||||
box.select();
|
||||
try {{
|
||||
if (!document.execCommand("copy")) throw new Error("exec");
|
||||
resolve();
|
||||
}} catch (err) {{ reject(err); }}
|
||||
document.body.removeChild(box);
|
||||
}});
|
||||
}}
|
||||
|
||||
function wecomCopy(value) {{
|
||||
if (window.ww && ww.setClipboardData) {{
|
||||
return ww.setClipboardData({{ data: value }});
|
||||
}}
|
||||
return Promise.reject(new Error("no-ww"));
|
||||
}}
|
||||
|
||||
function sign(kind) {{
|
||||
return function (url) {{
|
||||
return fetch("/inquiry-form/jsapi-signature?kind=" + encodeURIComponent(kind)
|
||||
+ "&url=" + encodeURIComponent(url))
|
||||
.then(function (r) {{
|
||||
if (!r.ok) throw new Error("sign");
|
||||
return r.json();
|
||||
}});
|
||||
}};
|
||||
}}
|
||||
|
||||
function tryCopy() {{
|
||||
return wecomCopy(text).catch(function () {{ return browserCopy(text); }})
|
||||
.then(function () {{ show(true, "已复制到剪贴板"); }})
|
||||
.catch(function () {{ show(false, "复制失败,请再点一次按钮"); }});
|
||||
}}
|
||||
|
||||
function boot() {{
|
||||
var ready = Promise.resolve();
|
||||
if (window.ww && ww.register && corpId) {{
|
||||
ready = ww.register({{
|
||||
corpId: corpId,
|
||||
agentId: agentId,
|
||||
jsApiList: ["setClipboardData"],
|
||||
getConfigSignature: sign("corp"),
|
||||
getAgentConfigSignature: sign("agent")
|
||||
}}).catch(function () {{ return null; }});
|
||||
}}
|
||||
ready.then(tryCopy).catch(tryCopy);
|
||||
}}
|
||||
|
||||
document.getElementById("btn").addEventListener("click", function () {{
|
||||
show(true, "正在复制…");
|
||||
tryCopy();
|
||||
}});
|
||||
if (document.readyState === "loading") {{
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
}} else {{
|
||||
boot();
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
复制工单号 H5 的短命 token。
|
||||
|
||||
本文件职责:签发/校验 token → 工单号;URL 只带 token,禁止带 userid/工单号。
|
||||
优先 Redis(HTTP 与 Worker 不同进程必须共享);单测无 Redis 时用内存。
|
||||
禁止:当六态;禁止把工单号拼进公网路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.redis_coord.keys import TOKEN_COPY_TICKET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 销售可能隔几天才点卡;7 天内同一 token 可重复打开复制。
|
||||
COPY_TTL_SECONDS = 7 * 24 * 60 * 60
|
||||
_FORM_FALLBACK = "https://ai.ytd-scm.com/inquiry-form"
|
||||
|
||||
_MEM: dict[str, tuple[str, float]] = {}
|
||||
_MEM_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def issue_copy_token(work_order_no: str, *, ttl_seconds: int = COPY_TTL_SECONDS) -> str:
|
||||
"""
|
||||
签发复制页 token。
|
||||
|
||||
副作用:写 Redis 或内存。工单号为空则返回空串,调用方不要出跳转按钮。
|
||||
"""
|
||||
wo = (work_order_no or "").strip()
|
||||
if not wo:
|
||||
return ""
|
||||
token = secrets.token_urlsafe(24)
|
||||
if _redis_set(token, wo, ttl_seconds):
|
||||
return token
|
||||
expires = time.time() + ttl_seconds
|
||||
with _MEM_LOCK:
|
||||
_MEM[token] = (wo, expires)
|
||||
logger.info("copy token 走内存 wo=%s(HTTP/Worker 分进程时页面会找不到)", wo)
|
||||
return token
|
||||
|
||||
|
||||
def lookup_copy_token(token: str) -> Optional[str]:
|
||||
"""校验 token 未过期;返回工单号或 None。不消费,可重复打开。"""
|
||||
raw = (token or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
wo = _redis_get(raw)
|
||||
if wo:
|
||||
return wo
|
||||
now = time.time()
|
||||
with _MEM_LOCK:
|
||||
hit = _MEM.get(raw)
|
||||
if not hit:
|
||||
return None
|
||||
wo, exp = hit
|
||||
if exp < now:
|
||||
_MEM.pop(raw, None)
|
||||
return None
|
||||
return wo
|
||||
|
||||
|
||||
def build_copy_ticket_url(work_order_no: str) -> str:
|
||||
"""
|
||||
拼复制页绝对地址:{inquiry-form}/copy/{token}。
|
||||
|
||||
域名来自环境变量,禁止写死测试/正式切换。缺省回合同域名,保证企微能打开。
|
||||
"""
|
||||
token = issue_copy_token(work_order_no)
|
||||
if not token:
|
||||
return ""
|
||||
return f"{_form_base()}/copy/{token}"
|
||||
|
||||
|
||||
def _form_base() -> str:
|
||||
settings = get_settings()
|
||||
form = (settings.inquiry_form_public_base_url or "").strip().rstrip("/")
|
||||
if form:
|
||||
return form
|
||||
pub = (settings.public_base_url or "").strip().rstrip("/")
|
||||
if pub:
|
||||
return f"{pub}/inquiry-form"
|
||||
return _FORM_FALLBACK
|
||||
|
||||
|
||||
def _redis_set(token: str, work_order_no: str, ttl_seconds: int) -> bool:
|
||||
client = _try_redis()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
client.raw.set(client.key(TOKEN_COPY_TICKET, token), work_order_no, ex=ttl_seconds)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("copy token Redis 写入失败:%s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _redis_get(token: str) -> Optional[str]:
|
||||
client = _try_redis()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
val = client.raw.get(client.key(TOKEN_COPY_TICKET, token))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("copy token Redis 读取失败:%s", exc)
|
||||
return None
|
||||
if val is None:
|
||||
return None
|
||||
text = val if isinstance(val, str) else val.decode("utf-8")
|
||||
return text.strip() or None
|
||||
|
||||
|
||||
def _try_redis():
|
||||
"""有 Redis 才用;单测 memory 后端也可用,保证同进程能读到。"""
|
||||
try:
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
|
||||
return get_redis_runtime(get_settings()).client
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
Reference in New Issue
Block a user