| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- """微信小程序「发货信息录入」upload_shipping_info,用于交易类订单资金解冻前的平台发货同步。
- 文档:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html
- """
- from __future__ import annotations
- import json
- import logging
- from datetime import datetime, timedelta
- from typing import Any, Optional, Tuple
- import httpx
- from sqlalchemy import and_
- from sqlalchemy.orm import Session
- from app.core.config import settings
- from app.crud.order import order_crud
- from app.crud.user import user_crud
- logger = logging.getLogger(__name__)
- _ACCESS_TOKEN: Optional[str] = None
- _ACCESS_EXPIRES_AT: Optional[datetime] = None
- # 微信:同城配送(无快递单号场景)
- _LOGISTICS_TYPE_SAME_CITY = 2
- _DELIVERY_MODE_UNIFIED = 1
- # 已发货 / 内容未变化 —— 视为幂等成功
- _IDEMPOTENT_ERRCODES = frozenset({10060002, 10060023})
- def _now_shanghai_iso_millis() -> str:
- try:
- from zoneinfo import ZoneInfo
- now = datetime.now(ZoneInfo("Asia/Shanghai"))
- except Exception:
- now = datetime.utcnow() + timedelta(hours=8)
- # 2022-12-15T13:29:35.120+08:00
- s = now.strftime("%Y-%m-%dT%H:%M:%S")
- ms = f"{now.microsecond // 1000:03d}"
- return f"{s}.{ms}+08:00"
- def _get_cgi_access_token() -> Optional[str]:
- global _ACCESS_TOKEN, _ACCESS_EXPIRES_AT
- now = datetime.now()
- if _ACCESS_TOKEN and _ACCESS_EXPIRES_AT and now < _ACCESS_EXPIRES_AT:
- return _ACCESS_TOKEN
- appid = (settings.WXPAY_MINI_APPID or "").strip()
- secret = (settings.WXPAY_SECRET or "").strip()
- if not appid or not secret:
- logger.warning("WXPAY_MINI_APPID / WXPAY_SECRET 未配置,无法获取 access_token")
- return None
- try:
- r = httpx.get(
- "https://api.weixin.qq.com/cgi-bin/token",
- params={"grant_type": "client_credential", "appid": appid, "secret": secret},
- timeout=10,
- )
- data = r.json()
- except Exception as e:
- logger.exception("获取微信 access_token 请求异常: %s", e)
- return None
- token = data.get("access_token")
- if not token:
- logger.error("获取微信 access_token 失败: %s", data)
- return None
- expires_in = int(data.get("expires_in") or 7200)
- _ACCESS_TOKEN = token
- _ACCESS_EXPIRES_AT = now + timedelta(seconds=max(60, expires_in - 300))
- return token
- def _sanitize_utf8_text(text: str) -> str:
- """确保文本为合法 UTF-8,避免微信接口 47007。"""
- if not text:
- return text
- return text.encode("utf-8", errors="ignore").decode("utf-8")
- def _build_item_desc(db: Session, order: Any) -> str:
- from app.models.models import BizOrderItem, BasProduct
- rows = (
- db.query(BasProduct.name)
- .select_from(BizOrderItem)
- .outerjoin(
- BasProduct,
- and_(BasProduct.id == BizOrderItem.product_id, BasProduct.is_del == 0),
- )
- .filter(BizOrderItem.order_id == order.id, BizOrderItem.is_del == 0)
- .order_by(BizOrderItem.create_at.asc())
- .limit(12)
- .all()
- )
- names = [str(n[0]).strip() for n in rows if n and n[0] and str(n[0]).strip()]
- if names:
- base = "、".join(names)
- if len(base) > 80:
- base = base[:77] + "..."
- text = f"{base}(单号{order.order_no})"
- else:
- text = f"生鲜配送订单(单号{order.order_no})"
- if len(text) > 120:
- text = text[:117] + "..."
- return _sanitize_utf8_text(text)
- def _post_upload_shipping_info(body: dict) -> Tuple[bool, Optional[int], str]:
- token = _get_cgi_access_token()
- if not token:
- return False, None, "无法获取微信 access_token"
- url = f"https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token={token}"
- try:
- # 微信要求 JSON 体为 UTF-8 字面量;httpx/json 默认 ensure_ascii=True 会把中文编成 \uXXXX 导致 47007
- payload = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
- r = httpx.post(
- url,
- content=payload,
- headers={"Content-Type": "application/json; charset=utf-8"},
- timeout=15,
- )
- data = r.json() if r.content else {}
- except Exception as e:
- logger.exception("upload_shipping_info 请求异常: %s", e)
- return False, None, f"请求微信平台异常:{e}"
- errcode = data.get("errcode")
- if errcode == 0:
- return True, 0, "ok"
- try:
- ec = int(errcode)
- except (TypeError, ValueError):
- ec = -1
- errmsg = (data.get("errmsg") or "").strip() or "unknown"
- return False, ec, errmsg
- def _resolve_payer_openid(db: Session, order: Any, wp: Any, client: Any) -> Tuple[Optional[str], Optional[str]]:
- """解析付款人 openid:支付流水 → 用户绑定 → 微信查单回填。"""
- payer_openid = (getattr(wp, "payer_openid", None) or "").strip()
- if not payer_openid and client:
- payer_openid = (getattr(client, "wx_mini_openid", None) or "").strip()
- if not payer_openid:
- out_trade_no = (getattr(wp, "out_trade_no", None) or "").strip()
- transaction_id = (getattr(wp, "transaction_id", None) or "").strip()
- if out_trade_no or transaction_id:
- from app.core.wechat_pay import get_wechat_pay_v2
- pay_client = get_wechat_pay_v2()
- q = pay_client.query_order(
- out_trade_no=out_trade_no or None,
- transaction_id=transaction_id or None,
- )
- if q.get("success"):
- data = q.get("data") or {}
- if data.get("return_code") == "SUCCESS" and data.get("result_code") == "SUCCESS":
- payer_openid = (data.get("openid") or "").strip()
- if payer_openid:
- wp.payer_openid = payer_openid
- wp.update_at = datetime.now()
- if order and getattr(order, "client_id", None):
- user_crud.ensure_wx_mini_openid(
- db,
- str(order.client_id),
- payer_openid,
- {"user_id": "system", "real_name": "微信支付查单回填"},
- )
- logger.info(
- "通过微信查单回填 payer_openid order_no=%s",
- getattr(order, "order_no", ""),
- )
- if not payer_openid:
- return None, "客户账号未绑定微信小程序,无法向微信平台录入发货信息(缺少 payer.openid)"
- return payer_openid, None
- def sync_trade_managed_shipping_for_delivered_order(db: Session, order: Any) -> Optional[str]:
- """
- 配送完成时:若为线上微信支付且已付,向微信录入发货信息(同城配送、统一发货)。
- 返回 None 表示无需同步或已成功;返回字符串为应展示给前端的错误说明(应阻止本地「确认送达」提交)。
- """
- if not order:
- return None
- if order_crud.normalize_payment_method(getattr(order, "payment_method", None)) != "online":
- return None
- if (getattr(order, "pay_status", None) or "").strip() != "paid":
- return None
- appid = (settings.WXPAY_MINI_APPID or "").strip()
- secret = (settings.WXPAY_SECRET or "").strip()
- if not appid or not secret:
- if settings.DEBUG:
- logger.warning("DEBUG:未配置小程序 AppID/Secret,跳过微信发货信息录入(线上单 %s)", getattr(order, "order_no", ""))
- return None
- return "服务端未配置微信小程序 AppID/Secret,无法向微信平台录入发货信息"
- from app.models.models import BizWechatPay, SysUser
- wp = (
- db.query(BizWechatPay)
- .filter(BizWechatPay.order_id == order.id, BizWechatPay.is_del == 0)
- .order_by(BizWechatPay.pay_time.desc(), BizWechatPay.create_at.desc())
- .first()
- )
- if not wp:
- return "线上支付订单缺少微信支付流水记录,无法录入发货信息"
- ts = (getattr(wp, "trade_state", None) or "").strip().upper()
- if ts and ts != "SUCCESS":
- return f"微信支付状态为「{getattr(wp, 'trade_state', '')}」,暂不可录入发货信息"
- transaction_id = (getattr(wp, "transaction_id", None) or "").strip()
- out_trade_no = (getattr(wp, "out_trade_no", None) or "").strip()
- mchid = (settings.WXPAY_MCHID or "").strip()
- client = db.get(SysUser, order.client_id)
- payer_openid, openid_err = _resolve_payer_openid(db, order, wp, client)
- if openid_err:
- return openid_err
- if transaction_id:
- order_key: dict[str, Any] = {"order_number_type": 2, "transaction_id": transaction_id}
- elif out_trade_no and mchid:
- order_key = {"order_number_type": 1, "mchid": mchid, "out_trade_no": out_trade_no}
- else:
- return "缺少微信支付单号(transaction_id)或商户单号+商户号,无法录入发货信息"
- item_desc = _build_item_desc(db, order)
- body = {
- "order_key": order_key,
- "logistics_type": _LOGISTICS_TYPE_SAME_CITY,
- "delivery_mode": _DELIVERY_MODE_UNIFIED,
- "shipping_list": [{"item_desc": item_desc}],
- "upload_time": _now_shanghai_iso_millis(),
- "payer": {"openid": payer_openid},
- }
- ok, errcode, errmsg = _post_upload_shipping_info(body)
- if ok:
- logger.info(
- "upload_shipping_info 成功 order_no=%s transaction_id=%s",
- getattr(order, "order_no", ""),
- transaction_id or out_trade_no,
- )
- return None
- if errcode in _IDEMPOTENT_ERRCODES:
- logger.info(
- "upload_shipping_info 幂等忽略 errcode=%s order_no=%s msg=%s",
- errcode,
- getattr(order, "order_no", ""),
- errmsg,
- )
- return None
- logger.error(
- "upload_shipping_info 失败 order_no=%s errcode=%s errmsg=%s",
- getattr(order, "order_no", ""),
- errcode,
- errmsg,
- )
- return f"微信平台发货信息录入失败({errcode}):{errmsg}"
|