2026-02-10 14:23:29 +08:00
|
|
|
|
"""
|
2026-03-18 17:18:23 +08:00
|
|
|
|
微信支付 API v3 封装(从 payment 迁入 app)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
"""
|
|
|
|
|
|
import json
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.core.logging import get_logger
|
2026-02-10 14:23:29 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import time
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from typing import Dict
|
2026-02-10 14:23:29 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.payment.payment_config import WeChatPayConfig
|
|
|
|
|
|
from app.features.payment.schemas import NotifyResult, PaymentResult, PaymentStatus
|
|
|
|
|
|
from app.features.payment.payment_exceptions import (
|
|
|
|
|
|
PaymentConfigError,
|
|
|
|
|
|
PaymentCreateError,
|
|
|
|
|
|
PaymentNotifyError,
|
|
|
|
|
|
PaymentQueryError,
|
|
|
|
|
|
)
|
2026-02-11 16:06:15 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger = get_logger(__name__)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_pem_key(key: str) -> str:
|
|
|
|
|
|
if not key or not key.strip():
|
|
|
|
|
|
return key
|
2026-02-11 16:06:15 +08:00
|
|
|
|
key = key.strip().lstrip("\ufeff").strip('"').strip("'")
|
2026-02-10 14:23:29 +08:00
|
|
|
|
key = key.replace("\\n", "\n")
|
|
|
|
|
|
key = key.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-11 16:06:15 +08:00
|
|
|
|
def _resolve_key_path(key_path: str) -> str:
|
|
|
|
|
|
if not key_path or not key_path.strip():
|
|
|
|
|
|
return key_path
|
|
|
|
|
|
key_path = key_path.strip()
|
|
|
|
|
|
if os.path.isabs(key_path):
|
|
|
|
|
|
return key_path
|
|
|
|
|
|
abs_cwd = os.path.abspath(key_path)
|
|
|
|
|
|
if os.path.isfile(abs_cwd):
|
|
|
|
|
|
return abs_cwd
|
|
|
|
|
|
try:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
# app/features/payment/wechat_client.py -> api/
|
|
|
|
|
|
api_dir = os.path.dirname(
|
|
|
|
|
|
os.path.dirname(
|
|
|
|
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-02-11 16:06:15 +08:00
|
|
|
|
abs_api = os.path.normpath(os.path.join(api_dir, key_path))
|
|
|
|
|
|
if os.path.isfile(abs_api):
|
|
|
|
|
|
return abs_api
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return abs_cwd
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-10 14:23:29 +08:00
|
|
|
|
class WeChatPayClient:
|
|
|
|
|
|
def __init__(self, config: WeChatPayConfig):
|
|
|
|
|
|
self._config = config
|
|
|
|
|
|
self._client = None
|
|
|
|
|
|
|
2026-02-11 16:06:15 +08:00
|
|
|
|
def ensure_client(self):
|
|
|
|
|
|
self._ensure_client()
|
|
|
|
|
|
|
2026-02-10 14:23:29 +08:00
|
|
|
|
def _ensure_client(self):
|
|
|
|
|
|
if not self._config.is_configured:
|
|
|
|
|
|
raise PaymentConfigError("微信支付配置不完整,请检查环境变量")
|
|
|
|
|
|
if self._client is None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from wechatpayv3 import WeChatPay, WeChatPayType
|
|
|
|
|
|
|
2026-02-11 16:06:15 +08:00
|
|
|
|
if self._config.private_key_path and self._config.private_key_path.strip():
|
|
|
|
|
|
key_path = _resolve_key_path(self._config.private_key_path)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
with open(key_path, "r", encoding="utf-8") as f:
|
|
|
|
|
|
private_key = f.read()
|
2026-02-11 16:06:15 +08:00
|
|
|
|
elif self._config.private_key and self._config.private_key.strip():
|
|
|
|
|
|
private_key = self._config.private_key.strip()
|
|
|
|
|
|
else:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
raise PaymentConfigError(
|
|
|
|
|
|
"未配置微信支付商户私钥:请设置 WECHAT_PAY_PRIVATE_KEY_PATH 或 WECHAT_PAY_PRIVATE_KEY"
|
|
|
|
|
|
)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
private_key = _normalize_pem_key(private_key)
|
|
|
|
|
|
if not private_key or "-----BEGIN" not in private_key:
|
|
|
|
|
|
raise PaymentConfigError(
|
2026-03-18 17:18:23 +08:00
|
|
|
|
"微信支付商户私钥格式错误:需为 PEM 格式。"
|
2026-02-10 14:23:29 +08:00
|
|
|
|
)
|
2026-02-11 16:06:15 +08:00
|
|
|
|
kwargs = dict(
|
2026-02-10 14:23:29 +08:00
|
|
|
|
wechatpay_type=WeChatPayType.APP,
|
|
|
|
|
|
mchid=self._config.mch_id,
|
|
|
|
|
|
private_key=private_key,
|
|
|
|
|
|
cert_serial_no=self._config.cert_serial_no,
|
|
|
|
|
|
appid=self._config.app_id,
|
|
|
|
|
|
apiv3_key=self._config.api_v3_key,
|
|
|
|
|
|
notify_url=self._config.notify_url,
|
|
|
|
|
|
)
|
2026-02-11 16:06:15 +08:00
|
|
|
|
if self._config.use_platform_public_key:
|
|
|
|
|
|
if self._config.platform_public_key_path and self._config.platform_public_key_path.strip():
|
|
|
|
|
|
key_path = _resolve_key_path(self._config.platform_public_key_path)
|
|
|
|
|
|
with open(key_path, "r", encoding="utf-8") as f:
|
|
|
|
|
|
platform_pub_key = _normalize_pem_key(f.read())
|
|
|
|
|
|
elif self._config.platform_public_key and self._config.platform_public_key.strip():
|
2026-03-18 17:18:23 +08:00
|
|
|
|
platform_pub_key = _normalize_pem_key(
|
|
|
|
|
|
self._config.platform_public_key.strip()
|
|
|
|
|
|
)
|
2026-02-11 16:06:15 +08:00
|
|
|
|
else:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
raise PaymentConfigError(
|
|
|
|
|
|
"平台公钥模式需设置 WECHAT_PAY_PLATFORM_PUBLIC_KEY 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"
|
|
|
|
|
|
)
|
2026-02-11 16:06:15 +08:00
|
|
|
|
if not platform_pub_key or "-----BEGIN" not in platform_pub_key:
|
|
|
|
|
|
raise PaymentConfigError("微信支付平台公钥格式错误,需为 PEM 格式")
|
|
|
|
|
|
kwargs["public_key"] = platform_pub_key
|
|
|
|
|
|
kwargs["public_key_id"] = self._config.platform_public_key_id.strip()
|
|
|
|
|
|
else:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
kwargs["timeout"] = (10, 25)
|
2026-02-11 16:06:15 +08:00
|
|
|
|
self._client = WeChatPay(**kwargs)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
logger.info("微信支付客户端初始化成功")
|
|
|
|
|
|
except FileNotFoundError:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
key_path = (
|
|
|
|
|
|
_resolve_key_path(self._config.private_key_path)
|
|
|
|
|
|
if self._config.private_key_path
|
|
|
|
|
|
else self._config.private_key_path
|
|
|
|
|
|
)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
raise PaymentConfigError(
|
2026-03-18 17:18:23 +08:00
|
|
|
|
f"微信支付商户私钥文件不存在: {key_path}"
|
2026-02-10 14:23:29 +08:00
|
|
|
|
)
|
|
|
|
|
|
except PaymentConfigError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
2026-02-11 16:06:15 +08:00
|
|
|
|
msg = str(e).strip()
|
2026-03-18 17:18:23 +08:00
|
|
|
|
if "platform certificate" in msg.lower():
|
2026-02-11 16:06:15 +08:00
|
|
|
|
raise PaymentConfigError(
|
2026-03-18 17:18:23 +08:00
|
|
|
|
"无法获取微信支付平台证书。请改用平台公钥模式:设置 "
|
|
|
|
|
|
"WECHAT_PAY_PLATFORM_PUBLIC_KEY 与 WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID。"
|
2026-02-11 16:06:15 +08:00
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
raise PaymentConfigError(f"微信支付客户端初始化失败: {e}")
|
2026-02-10 14:23:29 +08:00
|
|
|
|
|
|
|
|
|
|
def create_app_order(
|
|
|
|
|
|
self,
|
|
|
|
|
|
out_trade_no: str,
|
|
|
|
|
|
total_amount: int,
|
|
|
|
|
|
description: str,
|
|
|
|
|
|
) -> PaymentResult:
|
|
|
|
|
|
self._ensure_client()
|
|
|
|
|
|
try:
|
|
|
|
|
|
code, message = self._client.pay(
|
|
|
|
|
|
description=description,
|
|
|
|
|
|
out_trade_no=out_trade_no,
|
|
|
|
|
|
amount={"total": total_amount, "currency": "CNY"},
|
|
|
|
|
|
pay_type=self._get_pay_type(),
|
|
|
|
|
|
)
|
|
|
|
|
|
result = json.loads(message)
|
|
|
|
|
|
if code in range(200, 300):
|
|
|
|
|
|
prepay_id = result.get("prepay_id", "")
|
|
|
|
|
|
timestamp = str(int(time.time()))
|
2026-03-18 17:18:23 +08:00
|
|
|
|
nonce_str = out_trade_no
|
2026-02-10 14:23:29 +08:00
|
|
|
|
pay_params = self._client.sign(
|
|
|
|
|
|
data=[self._config.app_id, timestamp, nonce_str, prepay_id]
|
|
|
|
|
|
)
|
|
|
|
|
|
wechat_params = {
|
|
|
|
|
|
"appId": self._config.app_id,
|
|
|
|
|
|
"partnerId": self._config.mch_id,
|
|
|
|
|
|
"prepayId": prepay_id,
|
|
|
|
|
|
"nonceStr": nonce_str,
|
|
|
|
|
|
"timeStamp": timestamp,
|
|
|
|
|
|
"sign": pay_params,
|
|
|
|
|
|
"packageValue": "Sign=WXPay",
|
|
|
|
|
|
}
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.info("微信支付预订单创建成功: %s", out_trade_no)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
return PaymentResult(
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
payment_method="wechat",
|
|
|
|
|
|
out_trade_no=out_trade_no,
|
|
|
|
|
|
wechat_params=wechat_params,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_msg = result.get("message", "未知错误")
|
|
|
|
|
|
raise PaymentCreateError(f"微信支付下单失败: {error_msg}")
|
|
|
|
|
|
except PaymentCreateError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.error("微信支付预订单创建异常: %s", e)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
raise PaymentCreateError(f"微信支付下单异常: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
def verify_notify(self, headers: Dict[str, str], body: str) -> NotifyResult:
|
|
|
|
|
|
self._ensure_client()
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = self._client.callback(headers=headers, body=body)
|
|
|
|
|
|
if result:
|
|
|
|
|
|
event_data = result if isinstance(result, dict) else json.loads(result)
|
|
|
|
|
|
trade_state = event_data.get("trade_state", "")
|
|
|
|
|
|
out_trade_no = event_data.get("out_trade_no", "")
|
|
|
|
|
|
transaction_id = event_data.get("transaction_id", "")
|
|
|
|
|
|
amount_info = event_data.get("amount", {})
|
|
|
|
|
|
total_amount = amount_info.get("total", 0)
|
|
|
|
|
|
return NotifyResult(
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
out_trade_no=out_trade_no,
|
|
|
|
|
|
trade_no=transaction_id,
|
|
|
|
|
|
total_amount=total_amount,
|
|
|
|
|
|
trade_status=trade_state,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
raise PaymentNotifyError("微信支付回调验签失败")
|
2026-02-10 14:23:29 +08:00
|
|
|
|
except PaymentNotifyError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.error("微信支付回调处理异常: %s", e)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
raise PaymentNotifyError(f"微信支付回调处理失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
def query_order(self, out_trade_no: str) -> PaymentStatus:
|
|
|
|
|
|
self._ensure_client()
|
|
|
|
|
|
try:
|
|
|
|
|
|
code, message = self._client.query(out_trade_no=out_trade_no)
|
|
|
|
|
|
result = json.loads(message)
|
|
|
|
|
|
if code in range(200, 300):
|
|
|
|
|
|
trade_state = result.get("trade_state", "NOTPAY")
|
|
|
|
|
|
transaction_id = result.get("transaction_id", "")
|
|
|
|
|
|
amount_info = result.get("amount", {})
|
|
|
|
|
|
total_amount = amount_info.get("total", 0)
|
|
|
|
|
|
return PaymentStatus(
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
out_trade_no=out_trade_no,
|
|
|
|
|
|
trade_no=transaction_id,
|
|
|
|
|
|
trade_status=trade_state,
|
|
|
|
|
|
total_amount=total_amount,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
error_msg = result.get("message", "未知错误")
|
|
|
|
|
|
raise PaymentQueryError(f"查询微信支付订单失败: {error_msg}")
|
2026-02-10 14:23:29 +08:00
|
|
|
|
except PaymentQueryError:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.error("查询微信支付订单异常: %s", e)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
raise PaymentQueryError(f"查询微信支付订单异常: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
def close_order(self, out_trade_no: str) -> bool:
|
|
|
|
|
|
self._ensure_client()
|
|
|
|
|
|
try:
|
|
|
|
|
|
code, message = self._client.close(out_trade_no=out_trade_no)
|
|
|
|
|
|
if code in range(200, 300):
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.info("微信支付订单已关闭: %s", out_trade_no)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
return True
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.warning("关闭微信支付订单失败: %s - %s", code, message)
|
|
|
|
|
|
return False
|
2026-02-10 14:23:29 +08:00
|
|
|
|
except Exception as e:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger.error("关闭微信支付订单异常: %s", e)
|
2026-02-10 14:23:29 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _get_pay_type(self):
|
|
|
|
|
|
from wechatpayv3 import WeChatPayType
|
|
|
|
|
|
return WeChatPayType.APP
|