wechat_order_shipping.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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 logging
  6. from datetime import datetime, timedelta
  7. from typing import Any, Optional, Tuple
  8. import requests
  9. from sqlalchemy import and_
  10. from sqlalchemy.orm import Session
  11. from app.core.config import settings
  12. from app.crud.order import order_crud
  13. logger = logging.getLogger(__name__)
  14. _ACCESS_TOKEN: Optional[str] = None
  15. _ACCESS_EXPIRES_AT: Optional[datetime] = None
  16. # 微信:同城配送(无快递单号场景)
  17. _LOGISTICS_TYPE_SAME_CITY = 2
  18. _DELIVERY_MODE_UNIFIED = 1
  19. # 已发货 / 内容未变化 —— 视为幂等成功
  20. _IDEMPOTENT_ERRCODES = frozenset({10060002, 10060023})
  21. def _now_shanghai_iso_millis() -> str:
  22. try:
  23. from zoneinfo import ZoneInfo
  24. now = datetime.now(ZoneInfo("Asia/Shanghai"))
  25. except Exception:
  26. now = datetime.utcnow() + timedelta(hours=8)
  27. # 2022-12-15T13:29:35.120+08:00
  28. s = now.strftime("%Y-%m-%dT%H:%M:%S")
  29. ms = f"{now.microsecond // 1000:03d}"
  30. return f"{s}.{ms}+08:00"
  31. def _get_cgi_access_token() -> Optional[str]:
  32. global _ACCESS_TOKEN, _ACCESS_EXPIRES_AT
  33. now = datetime.now()
  34. if _ACCESS_TOKEN and _ACCESS_EXPIRES_AT and now < _ACCESS_EXPIRES_AT:
  35. return _ACCESS_TOKEN
  36. appid = (settings.WXPAY_MINI_APPID or "").strip()
  37. secret = (settings.WXPAY_SECRET or "").strip()
  38. if not appid or not secret:
  39. logger.warning("WXPAY_MINI_APPID / WXPAY_SECRET 未配置,无法获取 access_token")
  40. return None
  41. try:
  42. r = requests.get(
  43. "https://api.weixin.qq.com/cgi-bin/token",
  44. params={"grant_type": "client_credential", "appid": appid, "secret": secret},
  45. timeout=10,
  46. )
  47. data = r.json()
  48. except Exception as e:
  49. logger.exception("获取微信 access_token 请求异常: %s", e)
  50. return None
  51. token = data.get("access_token")
  52. if not token:
  53. logger.error("获取微信 access_token 失败: %s", data)
  54. return None
  55. expires_in = int(data.get("expires_in") or 7200)
  56. _ACCESS_TOKEN = token
  57. _ACCESS_EXPIRES_AT = now + timedelta(seconds=max(60, expires_in - 300))
  58. return token
  59. def _build_item_desc(db: Session, order: Any) -> str:
  60. from app.models.models import BizOrderItem, BasProduct
  61. rows = (
  62. db.query(BasProduct.name)
  63. .select_from(BizOrderItem)
  64. .outerjoin(
  65. BasProduct,
  66. and_(BasProduct.id == BizOrderItem.product_id, BasProduct.is_del == 0),
  67. )
  68. .filter(BizOrderItem.order_id == order.id, BizOrderItem.is_del == 0)
  69. .order_by(BizOrderItem.create_at.asc())
  70. .limit(12)
  71. .all()
  72. )
  73. names = [str(n[0]).strip() for n in rows if n and n[0] and str(n[0]).strip()]
  74. if names:
  75. base = "、".join(names)
  76. if len(base) > 80:
  77. base = base[:77] + "..."
  78. text = f"{base}(单号{order.order_no})"
  79. else:
  80. text = f"生鲜配送订单(单号{order.order_no})"
  81. if len(text) > 120:
  82. text = text[:117] + "..."
  83. return text
  84. def _post_upload_shipping_info(body: dict) -> Tuple[bool, Optional[int], str]:
  85. token = _get_cgi_access_token()
  86. if not token:
  87. return False, None, "无法获取微信 access_token"
  88. url = f"https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token={token}"
  89. try:
  90. r = requests.post(url, json=body, timeout=15)
  91. data = r.json() if r.content else {}
  92. except Exception as e:
  93. logger.exception("upload_shipping_info 请求异常: %s", e)
  94. return False, None, f"请求微信平台异常:{e}"
  95. errcode = data.get("errcode")
  96. if errcode == 0:
  97. return True, 0, "ok"
  98. try:
  99. ec = int(errcode)
  100. except (TypeError, ValueError):
  101. ec = -1
  102. errmsg = (data.get("errmsg") or "").strip() or "unknown"
  103. return False, ec, errmsg
  104. def sync_trade_managed_shipping_for_delivered_order(db: Session, order: Any) -> Optional[str]:
  105. """
  106. 配送完成时:若为线上微信支付且已付,向微信录入发货信息(同城配送、统一发货)。
  107. 返回 None 表示无需同步或已成功;返回字符串为应展示给前端的错误说明(应阻止本地「确认送达」提交)。
  108. """
  109. if not order:
  110. return None
  111. if order_crud.normalize_payment_method(getattr(order, "payment_method", None)) != "online":
  112. return None
  113. if (getattr(order, "pay_status", None) or "").strip() != "paid":
  114. return None
  115. appid = (settings.WXPAY_MINI_APPID or "").strip()
  116. secret = (settings.WXPAY_SECRET or "").strip()
  117. if not appid or not secret:
  118. if settings.DEBUG:
  119. logger.warning("DEBUG:未配置小程序 AppID/Secret,跳过微信发货信息录入(线上单 %s)", getattr(order, "order_no", ""))
  120. return None
  121. return "服务端未配置微信小程序 AppID/Secret,无法向微信平台录入发货信息"
  122. from app.models.models import BizWechatPay, SysUser
  123. wp = (
  124. db.query(BizWechatPay)
  125. .filter(BizWechatPay.order_id == order.id, BizWechatPay.is_del == 0)
  126. .order_by(BizWechatPay.pay_time.desc(), BizWechatPay.create_at.desc())
  127. .first()
  128. )
  129. if not wp:
  130. return "线上支付订单缺少微信支付流水记录,无法录入发货信息"
  131. ts = (getattr(wp, "trade_state", None) or "").strip().upper()
  132. if ts and ts != "SUCCESS":
  133. return f"微信支付状态为「{getattr(wp, 'trade_state', '')}」,暂不可录入发货信息"
  134. transaction_id = (getattr(wp, "transaction_id", None) or "").strip()
  135. out_trade_no = (getattr(wp, "out_trade_no", None) or "").strip()
  136. mchid = (settings.WXPAY_MCHID or "").strip()
  137. client = db.get(SysUser, order.client_id)
  138. payer_openid = (getattr(client, "wx_mini_openid", None) or "").strip() if client else ""
  139. if not payer_openid:
  140. return "客户账号未绑定微信小程序,无法向微信平台录入发货信息(缺少 payer.openid)"
  141. if transaction_id:
  142. order_key: dict[str, Any] = {"order_number_type": 2, "transaction_id": transaction_id}
  143. elif out_trade_no and mchid:
  144. order_key = {"order_number_type": 1, "mchid": mchid, "out_trade_no": out_trade_no}
  145. else:
  146. return "缺少微信支付单号(transaction_id)或商户单号+商户号,无法录入发货信息"
  147. item_desc = _build_item_desc(db, order)
  148. body = {
  149. "order_key": order_key,
  150. "logistics_type": _LOGISTICS_TYPE_SAME_CITY,
  151. "delivery_mode": _DELIVERY_MODE_UNIFIED,
  152. "shipping_list": [{"item_desc": item_desc}],
  153. "upload_time": _now_shanghai_iso_millis(),
  154. "payer": {"openid": payer_openid},
  155. }
  156. ok, errcode, errmsg = _post_upload_shipping_info(body)
  157. if ok:
  158. logger.info(
  159. "upload_shipping_info 成功 order_no=%s transaction_id=%s",
  160. getattr(order, "order_no", ""),
  161. transaction_id or out_trade_no,
  162. )
  163. return None
  164. if errcode in _IDEMPOTENT_ERRCODES:
  165. logger.info(
  166. "upload_shipping_info 幂等忽略 errcode=%s order_no=%s msg=%s",
  167. errcode,
  168. getattr(order, "order_no", ""),
  169. errmsg,
  170. )
  171. return None
  172. logger.error(
  173. "upload_shipping_info 失败 order_no=%s errcode=%s errmsg=%s",
  174. getattr(order, "order_no", ""),
  175. errcode,
  176. errmsg,
  177. )
  178. return f"微信平台发货信息录入失败({errcode}):{errmsg}"