落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+180 -12
View File
@@ -54,6 +54,170 @@ class WeComAppClient:
self._token_expire_at: float = 0.0
self._lock = threading.Lock()
def send_outbound(
self,
*,
touser: str,
content: str,
payload: dict[str, Any] | None = None,
) -> WeComSendResult:
"""
按 outbox 内容发应用消息:有模板卡先发卡,失败再回退文本。
副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。
"""
extra = dict(payload or {})
if extra.get("msgtype") == "file":
return self.send_file(
touser=touser,
filename=str(extra.get("filename") or "quote.xlsx"),
filepath=str(extra.get("filepath") or ""),
file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""),
)
if extra.get("msgtype") == "update_template_card":
return self.update_template_card(
touser=touser,
response_code=str(extra.get("response_code") or ""),
replace_name=str((extra.get("button") or {}).get("replace_name") or ""),
)
if extra.get("msgtype") == "template_card" and extra.get("template_card"):
card_result = self._post_message(
{
"touser": touser,
"msgtype": "template_card",
"agentid": self._agent_id,
"template_card": extra["template_card"],
"enable_id_trans": 0,
},
preview="[template_card]",
)
if card_result.ok:
return card_result
logger.warning(
"模板卡发送失败,回退文本 err=%s:%s",
card_result.errcode,
card_result.errmsg,
)
return self.send_text(touser=touser, content=content)
def update_template_card(
self,
*,
touser: str,
response_code: str,
replace_name: str,
) -> WeComSendResult:
"""
按回调 ResponseCode 更新原模板卡按钮文案,保持置灰不可再点。
失败不回落成新文本气泡,避免销售看到重复「已提交」。
"""
if not touser or not response_code or not replace_name:
return WeComSendResult(ok=True, errcode=0, errmsg="skip_card_update")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("更新模板卡取 token 失败")
return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/message/update_template_card"
body = {
"userids": [touser],
"agentid": self._agent_id,
"response_code": response_code,
"enable_id_trans": 0,
"button": {"replace_name": replace_name},
}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微 update_template_card 网络失败")
return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc))
errcode = int(data.get("errcode") or 0)
errmsg = str(data.get("errmsg") or "")
if errcode != 0:
logger.warning("更新模板卡失败 errcode=%s errmsg=%s", errcode, errmsg)
return WeComSendResult(ok=True, errcode=errcode, errmsg=errmsg, raw=data)
logger.info("企微模板卡已置灰 touser=%s", touser)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def send_file(
self,
*,
touser: str,
filename: str,
filepath: str = "",
file_b64: str = "",
) -> WeComSendResult:
"""
先上传临时素材再发文件消息。
字节来自本机路径或 Base64;失败回 ok=False 由 outbox 重试。
禁止在回调线程调用;本方法只给出站消费线程用。
"""
if not touser:
return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空")
raw = b""
if filepath:
try:
from pathlib import Path
raw = Path(filepath).read_bytes()
except OSError as exc:
return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}")
elif file_b64:
import base64
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}")
if not raw:
return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty")
media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw)
if not media_id:
return up
return self._post_message(
{
"touser": touser,
"msgtype": "file",
"agentid": self._agent_id,
"file": {"media_id": media_id},
},
preview=f"[file]{filename}",
)
def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]:
"""cgi-bin/media/upload;密钥不写日志。"""
if not self._corp_id or not self._secret:
return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
logger.exception("上传素材取 token 失败")
return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/media/upload"
try:
with httpx.Client(timeout=max(self._timeout, 30.0)) as client:
resp = client.post(
url,
params={"access_token": token, "type": "file"},
files={"media": (filename, content, "application/octet-stream")},
)
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.exception("企微 media/upload 网络失败")
return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
errcode = int(data.get("errcode") or 0)
media_id = str(data.get("media_id") or "")
if errcode != 0 or not media_id:
logger.warning("企微上传文件失败 errcode=%s", errcode)
return "", WeComSendResult(
ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data
)
return media_id, WeComSendResult(ok=True, errcode=0, raw=data)
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
"""
向指定企微 userid 发送文本应用消息。
@@ -61,11 +225,23 @@ class WeComAppClient:
参数:touser 必须是入站 sender_id;content 测试环境应由上层加 [test] 前缀。
副作用:出站 HTTP 调企微;失败返回 ok=False,不抛给回调线程(由 outbox 重试)。
"""
if not self._corp_id or not self._secret:
return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
if not touser or not content:
return WeComSendResult(ok=False, errcode=-1, errmsg="touser/content 为空")
return self._post_message(
{
"touser": touser,
"msgtype": "text",
"agentid": self._agent_id,
"text": {"content": content},
"safe": 0,
},
preview=content,
)
def _post_message(self, body: dict[str, Any], *, preview: str) -> WeComSendResult:
"""真正打企微 message/send;密钥不写日志。"""
if not self._corp_id or not self._secret:
return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置")
try:
token = self._get_token()
except Exception as exc: # noqa: BLE001
@@ -73,13 +249,6 @@ class WeComAppClient:
return WeComSendResult(ok=False, errcode=-1, errmsg=str(exc))
url = f"{self._api_base}/cgi-bin/message/send"
body = {
"touser": touser,
"msgtype": "text",
"agentid": self._agent_id,
"text": {"content": content},
"safe": 0,
}
try:
with httpx.Client(timeout=self._timeout) as client:
resp = client.post(url, params={"access_token": token}, json=body)
@@ -91,15 +260,14 @@ class WeComAppClient:
errcode = int(data.get("errcode") or 0)
errmsg = str(data.get("errmsg") or "")
if errcode != 0:
# token 失效则清空缓存,下次重试
if errcode in (40014, 42001, 42007):
with self._lock:
self._token = None
self._token_expire_at = 0.0
logger.warning("企微发送失败 errcode=%s errmsg=%s", errcode, errmsg)
return WeComSendResult(ok=False, errcode=errcode, errmsg=errmsg, raw=data)
preview = content if len(content) <= 200 else (content[:200] + "…")
logger.info("企微发送成功 touser=%s reply=%s", touser, preview)
shown = preview if len(preview) <= 200 else (preview[:200] + "…")
logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def _get_token(self) -> str: