让成交卡等报价文件真正发出后再出现,并收紧 PDF 顶部留白,避免抬头被裁或空一截。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 10:36:55 +08:00
co-authored by Cursor
parent cb3bf5238b
commit a7a8487eb3
22 changed files with 1630 additions and 126 deletions
+36 -3
View File
@@ -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]:
"""
+150
View File
@@ -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
@@ -270,6 +270,14 @@ class WeComAppClient:
logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def get_access_token(self) -> str:
"""
取出站/JS-SDK 用的 access_token。
只给同进程 H5 签名用;禁止写进页面或日志。
"""
return self._get_token()
def _get_token(self) -> str:
with self._lock:
now = time.time()
+51 -1
View File
@@ -190,6 +190,26 @@ class PostgresMessageStore:
logger.exception("pg outbox 入队失败")
raise
def has_outbound_dedupe(self, prefix: str) -> bool:
"""去重键是否已出过站(前缀匹配)。跨进程看成交卡/文件是否已发。"""
head = (prefix or "").strip()
if not head:
return False
try:
with connect_psycopg(self._dsn) as conn:
row = conn.execute(
"""
SELECT 1 FROM agent_outbox
WHERE dedupe_key LIKE %s
LIMIT 1
""",
(head + "%",),
).fetchone()
return bool(row)
except Exception:
logger.exception("查 outbox 去重前缀失败 prefix=%s", head)
return False
def claim_outbound(self) -> Optional[OutboxItem]:
timeout = int(self._claim_timeout)
with connect_psycopg(self._dsn) as conn:
@@ -203,7 +223,7 @@ class PostgresMessageStore:
AND claimed_at IS NOT NULL
AND claimed_at < NOW() - (%s || ' seconds')::interval
)
ORDER BY created_at
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
@@ -294,6 +314,36 @@ class PostgresMessageStore:
def wait_outbox(self, timeout: float = 1.0) -> None:
self._outbox_event.wait(timeout=timeout)
def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool:
"""
等到指定出站已发送完成或失败。
查价前先等询价确认发出,避免暂无报价卡抢先。只等这一条。
"""
raw = (item_id or "").strip()
if not raw:
return False
started = time.time()
deadline = started + max(0.1, float(timeout_sec))
saw_claim = False
while time.time() < deadline:
with connect_psycopg(self._dsn) as conn:
row = conn.execute(
"SELECT state FROM agent_outbox WHERE id = %s",
(raw,),
).fetchone()
state = str(row[0] or "") if row else ""
if state in {"done", "failed"}:
return state == "done"
if state == "claimed":
saw_claim = True
if not saw_claim and state == "pending" and time.time() - started >= 0.4:
return True
if not state and time.time() - started >= 0.3:
return True
self._outbox_event.wait(0.05)
return False
def stats(self) -> dict[str, Any]:
with connect_psycopg(self._dsn) as conn:
inbox = conn.execute(
+39
View File
@@ -163,6 +163,14 @@ class MemoryMessageStore:
logger.info("outbox 入队 id=%s touser=%s", item.id, touser)
return True, item.id
def has_outbound_dedupe(self, prefix: str) -> bool:
"""去重键是否已出过站(前缀匹配)。给 wait_file 判断文件/成交卡是否已发。"""
head = (prefix or "").strip()
if not head:
return False
with self._lock:
return any(str(k).startswith(head) for k in self._seen_out)
def claim_outbound(self) -> Optional[OutboxItem]:
"""认领一条 pending/超时 outbox。"""
now = time.time()
@@ -207,6 +215,37 @@ class MemoryMessageStore:
def wait_outbox(self, timeout: float = 1.0) -> None:
self._outbox_event.wait(timeout=timeout)
def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool:
"""
等到指定出站已发送完成或失败。
查价前必须先等询价确认发出,避免暂无报价卡抢先到销售手机。
只等这一条,不锁其它工单。超时返回 False。
"""
raw = (item_id or "").strip()
if not raw:
return False
started = time.time()
deadline = started + max(0.1, float(timeout_sec))
saw_claim = False
while time.time() < deadline:
state = ""
with self._lock:
item = self._outbox.get(raw)
if item:
state = item.state
if state in {TaskState.DONE, TaskState.FAILED}:
return state == TaskState.DONE
if state == TaskState.CLAIMED:
saw_claim = True
# 单测没有出站线程:一直 PENDING 就不要空等 12 秒。测服有出站线程会立刻认领。
if not saw_claim and state == TaskState.PENDING and time.time() - started >= 0.4:
return True
if not state and time.time() - started >= 0.3:
return True
self._outbox_event.wait(0.05)
return False
def stats(self) -> dict[str, Any]:
with self._lock:
return {
@@ -40,4 +40,6 @@ class MessageStore(Protocol):
def wait_outbox(self, timeout: float = 1.0) -> None: ...
def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool: ...
def stats(self) -> dict[str, Any]: ...
+110
View File
@@ -0,0 +1,110 @@
"""
企微 JS-SDK 签名(给复制页写剪贴板)。
本文件职责:取 jsapi_ticket、按当前页 URL 签名。请求线程只打缓存/短 HTTP。
禁止:把 access_token / ticket 写进页面或日志;禁止在这里改工单。
"""
from __future__ import annotations
import hashlib
import logging
import secrets
import time
from typing import Any, Optional
import httpx
from agent.channel.outbox.sender import WeComAppClient
from agent.config import get_settings
from agent.redis_coord.keys import TOKEN_JSAPI
logger = logging.getLogger(__name__)
_TICKET_TTL = 3600
def build_jsapi_signature(*, url: str, kind: str = "corp") -> dict[str, Any]:
"""
给前端 ww.register 用的签名。
kind=corp → get_jsapi_ticket;kind=agent → agent_config ticket。
URL 必须与浏览器地址栏一致(不含 hash)。
"""
page = (url or "").strip()
if not page:
raise ValueError("url_empty")
ticket = _jsapi_ticket(kind="agent" if kind == "agent" else "corp")
if not ticket:
raise RuntimeError("jsapi_ticket_missing")
nonce = secrets.token_hex(8)
timestamp = str(int(time.time()))
raw = f"jsapi_ticket={ticket}&noncestr={nonce}&timestamp={timestamp}&url={page}"
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return {
"timestamp": int(timestamp),
"nonceStr": nonce,
"signature": signature,
}
def _jsapi_ticket(*, kind: str) -> str:
cached = _cache_get(kind)
if cached:
return cached
settings = get_settings()
if not (settings.wecom_corp_id and settings.wecom_secret):
return ""
client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=settings.wecom_secret,
agent_id=int(settings.wecom_agent_id or "0") or 0,
)
token = client.get_access_token()
if kind == "agent":
api = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get"
params = {"access_token": token, "type": "agent_config"}
else:
api = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket"
params = {"access_token": token}
with httpx.Client(timeout=8.0) as http:
data = http.get(api, params=params).json()
errcode = int(data.get("errcode") or 0)
ticket = str(data.get("ticket") or "")
if errcode != 0 or not ticket:
logger.warning("jsapi ticket 失败 kind=%s errcode=%s", kind, errcode)
return ""
_cache_set(kind, ticket)
return ticket
def _cache_get(kind: str) -> Optional[str]:
redis = _try_redis()
if redis is None:
return None
try:
val = redis.raw.get(redis.key(TOKEN_JSAPI, kind))
except Exception: # noqa: BLE001
return None
if val is None:
return None
return val if isinstance(val, str) else val.decode("utf-8")
def _cache_set(kind: str, ticket: str) -> None:
redis = _try_redis()
if redis is None:
return
try:
redis.raw.set(redis.key(TOKEN_JSAPI, kind), ticket, ex=_TICKET_TTL)
except Exception as exc: # noqa: BLE001
logger.warning("jsapi ticket 缓存失败:%s", exc)
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