wechat_order_shipping.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. """微信小程序「发货信息录入」upload_shipping_info,用于交易类订单资金解冻前的平台发货同步。
  2. 文档:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
  3. """
  4. from __future__ import annotations
  5. import json
  6. import logging
  7. from datetime import datetime, timedelta
  8. from typing import Any, Optional, Tuple
  9. import httpx
  10. from sqlalchemy import and_
  11. from sqlalchemy.orm import Session
  12. from app.core.config import settings
  13. from app.crud.order import order_crud
  14. from app.crud.user import user_crud
  15. logger = logging.getLogger(__name__)
  16. _ACCESS_TOKEN: Optional[str] = None
  17. _ACCESS_EXPIRES_AT: Optional[datetime] = None
  18. # 微信:同城配送(无快递单号场景)
  19. _LOGISTICS_TYPE_SAME_CITY = 2
  20. _DELIVERY_MODE_UNIFIED = 1
  21. # 已发货 / 内容未变化 —— 视为幂等成功
  22. _IDEMPOTENT_ERRCODES = frozenset({10060002, 10060023})
  23. def _now_shanghai_iso_millis() -> str:
  24. try:
  25. from zoneinfo import ZoneInfo
  26. now = datetime.now(ZoneInfo("Asia/Shanghai"))
  27. except Exception:
  28. now = datetime.utcnow() + timedelta(hours=8)
  29. # 2022-12-15T13:29:35.120+08:00
  30. s = now.strftime("%Y-%m-%dT%H:%M:%S")
  31. ms = f"{now.microsecond // 1000:03d}"
  32. return f"{s}.{ms}+08:00"
  33. def _get_cgi_access_token() -> Optional[str]:
  34. global _ACCESS_TOKEN, _ACCESS_EXPIRES_AT
  35. now = datetime.now()
  36. if _ACCESS_TOKEN and _ACCESS_EXPIRES_AT and now < _ACCESS_EXPIRES_AT:
  37. return _ACCESS_TOKEN
  38. appid = (settings.WXPAY_MINI_APPID or "").strip()
  39. secret = (settings.WXPAY_SECRET or "").strip()
  40. if not appid or not secret:
  41. logger.warning("WXPAY_MINI_APPID / WXPAY_SECRET 未配置,无法获取 access_token")
  42. return None
  43. try:
  44. r = httpx.get(
  45. "https://api.weixin.qq.com/cgi-bin/token",
  46. params={"grant_type": "client_credential", "appid": appid, "secret": secret},
  47. timeout=10,
  48. )
  49. data = r.json()
  50. except Exception as e:
  51. logger.exception("获取微信 access_token 请求异常: %s", e)
  52. return None
  53. token = data.get("access_token")
  54. if not token:
  55. logger.error("获取微信 access_token 失败: %s", data)
  56. return None
  57. expires_in = int(data.get("expires_in") or 7200)
  58. _ACCESS_TOKEN = token
  59. _ACCESS_EXPIRES_AT = now + timedelta(seconds=max(60, expires_in - 300))
  60. return token
  61. def _sanitize_utf8_text(text: str) -> str:
  62. """确保文本为合法 UTF-8,避免微信接口 47007。"""
  63. if not text:
  64. return text
  65. return text.encode("utf-8", errors="ignore").decode("utf-8")
  66. def _build_item_desc(db: Session, order: Any) -> str:
  67. from app.models.models import BizOrderItem, BasProduct
  68. rows = (
  69. db.query(BasProduct.name)
  70. .select_from(BizOrderItem)
  71. .outerjoin(
  72. BasProduct,
  73. and_(BasProduct.id == BizOrderItem.product_id, BasProduct.is_del == 0),
  74. )
  75. .filter(BizOrderItem.order_id == order.id, BizOrderItem.is_del == 0)
  76. .order_by(BizOrderItem.create_at.asc())
  77. .limit(12)
  78. .all()
  79. )
  80. names = [str(n[0]).strip() for n in rows if n and n[0] and str(n[0]).strip()]
  81. if names:
  82. base = "、".join(names)
  83. if len(base) > 80:
  84. base = base[:77] + "..."
  85. text = f"{base}(单号{order.order_no})"
  86. else:
  87. text = f"生鲜配送订单(单号{order.order_no})"
  88. if len(text) > 120:
  89. text = text[:117] + "..."
  90. return _sanitize_utf8_text(text)
  91. def _post_upload_shipping_info(body: dict) -> Tuple[bool, Optional[int], str]:
  92. token = _get_cgi_access_token()
  93. if not token:
  94. return False, None, "无法获取微信 access_token"
  95. url = f"https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token={token}"
  96. try:
  97. # 微信要求 JSON 体为 UTF-8 字面量;httpx/json 默认 ensure_ascii=True 会把中文编成 \uXXXX 导致 47007
  98. payload = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
  99. r = httpx.post(
  100. url,
  101. content=payload,
  102. headers={"Content-Type": "application/json; charset=utf-8"},
  103. timeout=15,
  104. )
  105. data = r.json() if r.content else {}
  106. except Exception as e:
  107. logger.exception("upload_shipping_info 请求异常: %s", e)
  108. return False, None, f"请求微信平台异常:{e}"
  109. errcode = data.get("errcode")
  110. if errcode == 0:
  111. return True, 0, "ok"
  112. try:
  113. ec = int(errcode)
  114. except (TypeError, ValueError):
  115. ec = -1
  116. errmsg = (data.get("errmsg") or "").strip() or "unknown"
  117. return False, ec, errmsg
  118. def _resolve_payer_openid(db: Session, order: Any, wp: Any, client: Any) -> Tuple[Optional[str], Optional[str]]:
  119. """解析付款人 openid:支付流水 → 用户绑定 → 微信查单回填。"""
  120. payer_openid = (getattr(wp, "payer_openid", None) or "").strip()
  121. if not payer_openid and client:
  122. payer_openid = (getattr(client, "wx_mini_openid", None) or "").strip()
  123. if not payer_openid:
  124. out_trade_no = (getattr(wp, "out_trade_no", None) or "").strip()
  125. transaction_id = (getattr(wp, "transaction_id", None) or "").strip()
  126. if out_trade_no or transaction_id:
  127. from app.core.wechat_pay import get_wechat_pay_v2
  128. pay_client = get_wechat_pay_v2()
  129. q = pay_client.query_order(
  130. out_trade_no=out_trade_no or None,
  131. transaction_id=transaction_id or None,
  132. )
  133. if q.get("success"):
  134. data = q.get("data") or {}
  135. if data.get("return_code") == "SUCCESS" and data.get("result_code") == "SUCCESS":
  136. payer_openid = (data.get("openid") or "").strip()
  137. if payer_openid:
  138. wp.payer_openid = payer_openid
  139. wp.update_at = datetime.now()
  140. if order and getattr(order, "client_id", None):
  141. user_crud.ensure_wx_mini_openid(
  142. db,
  143. str(order.client_id),
  144. payer_openid,
  145. {"user_id": "system", "real_name": "微信支付查单回填"},
  146. )
  147. logger.info(
  148. "通过微信查单回填 payer_openid order_no=%s",
  149. getattr(order, "order_no", ""),
  150. )
  151. if not payer_openid:
  152. return None, "客户账号未绑定微信小程序,无法向微信平台录入发货信息(缺少 payer.openid)"
  153. return payer_openid, None
  154. def sync_trade_managed_shipping_for_delivered_order(db: Session, order: Any) -> Optional[str]:
  155. """
  156. 配送完成时:若为线上微信支付且已付,向微信录入发货信息(同城配送、统一发货)。
  157. 返回 None 表示无需同步或已成功;返回字符串为应展示给前端的错误说明(应阻止本地「确认送达」提交)。
  158. """
  159. if not order:
  160. return None
  161. if order_crud.normalize_payment_method(getattr(order, "payment_method", None)) != "online":
  162. return None
  163. if (getattr(order, "pay_status", None) or "").strip() != "paid":
  164. return None
  165. appid = (settings.WXPAY_MINI_APPID or "").strip()
  166. secret = (settings.WXPAY_SECRET or "").strip()
  167. if not appid or not secret:
  168. if settings.DEBUG:
  169. logger.warning("DEBUG:未配置小程序 AppID/Secret,跳过微信发货信息录入(线上单 %s)", getattr(order, "order_no", ""))
  170. return None
  171. return "服务端未配置微信小程序 AppID/Secret,无法向微信平台录入发货信息"
  172. from app.models.models import BizWechatPay, SysUser
  173. wp = (
  174. db.query(BizWechatPay)
  175. .filter(BizWechatPay.order_id == order.id, BizWechatPay.is_del == 0)
  176. .order_by(BizWechatPay.pay_time.desc(), BizWechatPay.create_at.desc())
  177. .first()
  178. )
  179. if not wp:
  180. return "线上支付订单缺少微信支付流水记录,无法录入发货信息"
  181. ts = (getattr(wp, "trade_state", None) or "").strip().upper()
  182. if ts and ts != "SUCCESS":
  183. return f"微信支付状态为「{getattr(wp, 'trade_state', '')}」,暂不可录入发货信息"
  184. transaction_id = (getattr(wp, "transaction_id", None) or "").strip()
  185. out_trade_no = (getattr(wp, "out_trade_no", None) or "").strip()
  186. mchid = (settings.WXPAY_MCHID or "").strip()
  187. client = db.get(SysUser, order.client_id)
  188. payer_openid, openid_err = _resolve_payer_openid(db, order, wp, client)
  189. if openid_err:
  190. return openid_err
  191. if transaction_id:
  192. order_key: dict[str, Any] = {"order_number_type": 2, "transaction_id": transaction_id}
  193. elif out_trade_no and mchid:
  194. order_key = {"order_number_type": 1, "mchid": mchid, "out_trade_no": out_trade_no}
  195. else:
  196. return "缺少微信支付单号(transaction_id)或商户单号+商户号,无法录入发货信息"
  197. item_desc = _build_item_desc(db, order)
  198. body = {
  199. "order_key": order_key,
  200. "logistics_type": _LOGISTICS_TYPE_SAME_CITY,
  201. "delivery_mode": _DELIVERY_MODE_UNIFIED,
  202. "shipping_list": [{"item_desc": item_desc}],
  203. "upload_time": _now_shanghai_iso_millis(),
  204. "payer": {"openid": payer_openid},
  205. }
  206. ok, errcode, errmsg = _post_upload_shipping_info(body)
  207. if ok:
  208. logger.info(
  209. "upload_shipping_info 成功 order_no=%s transaction_id=%s",
  210. getattr(order, "order_no", ""),
  211. transaction_id or out_trade_no,
  212. )
  213. return None
  214. if errcode in _IDEMPOTENT_ERRCODES:
  215. logger.info(
  216. "upload_shipping_info 幂等忽略 errcode=%s order_no=%s msg=%s",
  217. errcode,
  218. getattr(order, "order_no", ""),
  219. errmsg,
  220. )
  221. return None
  222. logger.error(
  223. "upload_shipping_info 失败 order_no=%s errcode=%s errmsg=%s",
  224. getattr(order, "order_no", ""),
  225. errcode,
  226. errmsg,
  227. )
  228. return f"微信平台发货信息录入失败({errcode}):{errmsg}"