wechat_mp_message.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. """微信服务号模板消息:OA 审批/抄送通知。"""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any, Dict
  5. import httpx
  6. from app.core.config import settings
  7. from app.services.wechat_mp_auth import WechatMpAuthError, get_mp_access_token
  8. logger = logging.getLogger(__name__)
  9. _WX_TEMPLATE_SEND = "https://api.weixin.qq.com/cgi-bin/message/template/send"
  10. # 微信 thing 类字段建议约 20 汉字;按字符保守截断
  11. _THING_MAX_CHARS = 20
  12. def _truncate_thing(value: str, max_chars: int = _THING_MAX_CHARS) -> str:
  13. s = (value or "").strip()
  14. if len(s) <= max_chars:
  15. return s
  16. return s[:max_chars]
  17. def _build_oa_data(
  18. *,
  19. title: str,
  20. applicant: str,
  21. node_name: str,
  22. time_text: str,
  23. process_name: str = "",
  24. title_key: str = "",
  25. applicant_key: str = "",
  26. node_key: str = "",
  27. time_key: str = "",
  28. process_key: str = "",
  29. ) -> Dict[str, Dict[str, str]]:
  30. """按字段名组装 data;字段名留空则跳过(兼容临时少字段模板)。"""
  31. data: Dict[str, Dict[str, str]] = {}
  32. title_key = (title_key or "").strip()
  33. applicant_key = (applicant_key or "").strip()
  34. node_key = (node_key or "").strip()
  35. time_key = (time_key or "").strip()
  36. process_key = (process_key or "").strip()
  37. if title_key:
  38. data[title_key] = {"value": _truncate_thing(title)}
  39. if applicant_key:
  40. data[applicant_key] = {"value": _truncate_thing(applicant)}
  41. if node_key:
  42. data[node_key] = {"value": _truncate_thing(node_name)}
  43. if time_key:
  44. data[time_key] = {"value": (time_text or "").strip()}
  45. if process_key:
  46. data[process_key] = {"value": _truncate_thing(process_name)}
  47. return data
  48. def _send_template(
  49. *,
  50. mp_openid: str,
  51. template_id: str,
  52. data: Dict[str, Any],
  53. pagepath: str,
  54. ) -> Dict[str, Any]:
  55. oid = (mp_openid or "").strip()
  56. if not oid:
  57. return {"success": False, "error": "missing_mp_openid"}
  58. tid = (template_id or "").strip()
  59. if not tid:
  60. return {"success": False, "error": "missing_template_id"}
  61. mini_appid = (settings.WXPAY_MINI_APPID or "").strip()
  62. if not mini_appid:
  63. return {"success": False, "error": "missing_mini_appid"}
  64. try:
  65. access_token = get_mp_access_token()
  66. except WechatMpAuthError as exc:
  67. logger.warning("mp template send aborted: %s", exc)
  68. return {"success": False, "error": str(exc)}
  69. path = (pagepath or "").lstrip("/")
  70. use_mini_jump = bool(mini_appid and path)
  71. payload: Dict[str, Any] = {
  72. "touser": oid,
  73. "template_id": tid,
  74. "data": data,
  75. }
  76. if use_mini_jump:
  77. payload["miniprogram"] = {"appid": mini_appid, "pagepath": path}
  78. def _post(body: Dict[str, Any]) -> Dict[str, Any]:
  79. with httpx.Client(timeout=15.0) as client:
  80. r = client.post(
  81. f"{_WX_TEMPLATE_SEND}?access_token={access_token}",
  82. json=body,
  83. )
  84. r.raise_for_status()
  85. return r.json()
  86. try:
  87. result = _post(payload)
  88. except Exception as exc:
  89. logger.exception("mp template send request failed")
  90. return {"success": False, "error": str(exc)}
  91. jumped = use_mini_jump
  92. # 服务号未关联小程序 / 页面未发布时微信返回 40165;降级为纯模板通知,保证先可达
  93. if result.get("errcode") == 40165 and use_mini_jump:
  94. logger.warning(
  95. "mp template miniprogram jump rejected (40165), retry without jump: %s",
  96. result.get("errmsg"),
  97. )
  98. jumped = False
  99. payload_no_jump = {
  100. "touser": oid,
  101. "template_id": tid,
  102. "data": data,
  103. }
  104. try:
  105. result = _post(payload_no_jump)
  106. except Exception as exc:
  107. logger.exception("mp template fallback send request failed")
  108. return {"success": False, "error": str(exc)}
  109. if result.get("errcode") in (0, None):
  110. logger.info(
  111. "mp template send ok: openid=%s msgid=%s jumped=%s",
  112. oid,
  113. result.get("msgid"),
  114. jumped,
  115. )
  116. return {"success": True, "msgid": result.get("msgid")}
  117. logger.error("mp template send failed: %s", result)
  118. return {
  119. "success": False,
  120. "error": result.get("errmsg") or "send_failed",
  121. "errcode": result.get("errcode"),
  122. }
  123. def send_oa_approval_notify(
  124. *,
  125. mp_openid: str,
  126. pagepath: str,
  127. title: str,
  128. applicant: str,
  129. node_name: str,
  130. time_text: str,
  131. process_name: str = "",
  132. ) -> dict:
  133. return _send_template(
  134. mp_openid=mp_openid,
  135. template_id=settings.WX_MP_TEMPLATE_APPROVAL,
  136. data=_build_oa_data(
  137. title=title,
  138. applicant=applicant,
  139. node_name=node_name,
  140. time_text=time_text,
  141. process_name=process_name,
  142. title_key=settings.WX_MP_TPL_FIELD_TITLE,
  143. applicant_key=settings.WX_MP_TPL_FIELD_APPLICANT,
  144. node_key=settings.WX_MP_TPL_FIELD_NODE,
  145. time_key=settings.WX_MP_TPL_FIELD_TIME,
  146. process_key=settings.WX_MP_TPL_FIELD_PROCESS,
  147. ),
  148. pagepath=pagepath,
  149. )
  150. def send_oa_cc_notify(
  151. *,
  152. mp_openid: str,
  153. pagepath: str,
  154. title: str,
  155. applicant: str,
  156. node_name: str,
  157. time_text: str,
  158. process_name: str = "",
  159. ) -> dict:
  160. return _send_template(
  161. mp_openid=mp_openid,
  162. template_id=settings.WX_MP_TEMPLATE_CC,
  163. data=_build_oa_data(
  164. title=title,
  165. applicant=applicant,
  166. node_name=node_name,
  167. time_text=time_text,
  168. process_name=process_name,
  169. title_key=settings.WX_MP_TPL_CC_FIELD_TITLE,
  170. applicant_key=settings.WX_MP_TPL_CC_FIELD_APPLICANT,
  171. node_key="", # 抄送模板无当前环节
  172. time_key=settings.WX_MP_TPL_CC_FIELD_TIME,
  173. process_key=settings.WX_MP_TPL_CC_FIELD_PROCESS,
  174. ),
  175. pagepath=pagepath,
  176. )
  177. def send_oa_applicant_progress_notify(
  178. *,
  179. mp_openid: str,
  180. pagepath: str,
  181. business_name: str,
  182. process_name: str,
  183. approver_name: str,
  184. result: str,
  185. time_text: str,
  186. ) -> dict:
  187. """通知申请人:某步审批结果(const 枚举:通过/失败/转办/其他)。"""
  188. data: Dict[str, Dict[str, str]] = {}
  189. biz_key = (settings.WX_MP_TPL_PROGRESS_FIELD_BUSINESS or "").strip()
  190. process_key = (settings.WX_MP_TPL_PROGRESS_FIELD_PROCESS or "").strip()
  191. approver_key = (settings.WX_MP_TPL_PROGRESS_FIELD_APPROVER or "").strip()
  192. result_key = (settings.WX_MP_TPL_PROGRESS_FIELD_RESULT or "").strip()
  193. time_key = (settings.WX_MP_TPL_PROGRESS_FIELD_TIME or "").strip()
  194. if biz_key:
  195. data[biz_key] = {"value": _truncate_thing(business_name)}
  196. if process_key:
  197. data[process_key] = {"value": _truncate_thing(process_name)}
  198. if approver_key:
  199. data[approver_key] = {"value": _truncate_thing(approver_name)}
  200. if result_key:
  201. data[result_key] = {"value": (result or "").strip()}
  202. if time_key:
  203. data[time_key] = {"value": (time_text or "").strip()}
  204. return _send_template(
  205. mp_openid=mp_openid,
  206. template_id=settings.WX_MP_TEMPLATE_APPLICANT_PROGRESS,
  207. data=data,
  208. pagepath=pagepath,
  209. )
  210. def send_basic_experiment_delivery_remind(
  211. *,
  212. mp_openid: str,
  213. customer_name: str,
  214. product_name: str,
  215. quantity: str,
  216. contact_name: str,
  217. time_text: str,
  218. pagepath: str = "",
  219. ) -> dict:
  220. """基础实验:交付日前一天出库提醒。"""
  221. data: Dict[str, Dict[str, str]] = {}
  222. customer_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_CUSTOMER or "").strip()
  223. product_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_PRODUCT or "").strip()
  224. qty_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_QTY or "").strip()
  225. contact_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_CONTACT or "").strip()
  226. time_key = (settings.WX_MP_TPL_BE_DELIVERY_FIELD_TIME or "").strip()
  227. if customer_key:
  228. data[customer_key] = {"value": _truncate_thing(customer_name)}
  229. if product_key:
  230. data[product_key] = {"value": _truncate_thing(product_name)}
  231. if qty_key:
  232. data[qty_key] = {"value": (quantity or "").strip()}
  233. if contact_key:
  234. data[contact_key] = {"value": _truncate_thing(contact_name)}
  235. if time_key:
  236. data[time_key] = {"value": (time_text or "").strip()}
  237. return _send_template(
  238. mp_openid=mp_openid,
  239. template_id=settings.WX_MP_TEMPLATE_BASIC_EXPERIMENT_DELIVERY,
  240. data=data,
  241. pagepath=pagepath or "",
  242. )
  243. def send_sample_collect_notify(
  244. *,
  245. mp_openid: str,
  246. title: str,
  247. applicant: str,
  248. time_text: str,
  249. task_type: str,
  250. sample_type: str = "",
  251. pagepath: str = "",
  252. ) -> dict:
  253. """协议新增样本:样本采集工单通知。"""
  254. data: Dict[str, Dict[str, str]] = {}
  255. title_key = (settings.WX_MP_TPL_SC_FIELD_TITLE or "").strip()
  256. applicant_key = (settings.WX_MP_TPL_SC_FIELD_APPLICANT or "").strip()
  257. time_key = (settings.WX_MP_TPL_SC_FIELD_TIME or "").strip()
  258. type_key = (settings.WX_MP_TPL_SC_FIELD_TYPE or "").strip()
  259. sample_type_key = (settings.WX_MP_TPL_SC_FIELD_SAMPLE_TYPE or "").strip()
  260. if title_key:
  261. data[title_key] = {"value": _truncate_thing(title)}
  262. if applicant_key:
  263. data[applicant_key] = {"value": _truncate_thing(applicant)}
  264. if time_key:
  265. data[time_key] = {"value": (time_text or "").strip()}
  266. if type_key:
  267. # phrase:纯汉字短词,不做 thing 截断以外的改写
  268. data[type_key] = {"value": (task_type or "").strip()}
  269. if sample_type_key:
  270. # thing 至少 1 个字符;没有样本类型时不传该字段,避免空值被微信拒绝
  271. sample_type_value = _truncate_thing(sample_type)
  272. if sample_type_value:
  273. data[sample_type_key] = {"value": sample_type_value}
  274. return _send_template(
  275. mp_openid=mp_openid,
  276. template_id=settings.WX_MP_TEMPLATE_SAMPLE_COLLECT,
  277. data=data,
  278. pagepath=pagepath or "",
  279. )