6432497af1
提现申请改为先扣款并进入待审核,审核通过后才发起微信打款,审核拒绝或微信失败时自动退款。 新增运营后台提现列表的关键词搜索、日期筛选、快捷筛选和排序参数,并支持批量通过、批量拒绝、批量刷新查单。 新增微信支付配置健康检查、现金账本校验、自动对账 worker 和单实例保护。 新增数据库唯一索引,约束同一用户未完成提现和同一提现单重复退款,提升并发安全性。 --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #27 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
388 lines
15 KiB
Python
388 lines
15 KiB
Python
"""微信支付 V3 —— 商家转账到零钱(提现)。
|
|
|
|
移植自 elderhelper 的 wxpay.py,改成从 [app.core.config.settings] 读配置、密钥懒加载
|
|
(文件缺失不在 import 时炸进程,只在真正调用转账时报错,与极光私钥同款策略)。
|
|
|
|
只实现提现需要的两个接口:
|
|
- create_transfer:发起转账(商家转账到零钱)
|
|
- query_transfer :按商户单号查转账单状态
|
|
签名用商户 API 私钥(RSA-SHA256/PKCS1v15),敏感字段(实名)用微信支付平台公钥 OAEP 加密。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import certifi
|
|
import httpx
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
|
|
from app.core.config import settings
|
|
|
|
_API_HOST = "https://api.mch.weixin.qq.com"
|
|
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
|
|
# 免确认收款授权(用户授权免确认模式)
|
|
_AUTH_PATH = "/v3/fund-app/mch-transfer/user-confirm-authorization"
|
|
_PRE_TRANSFER_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/pre-transfer-with-authorization"
|
|
_TRANSFER_WITH_AUTH_PATH = "/v3/fund-app/mch-transfer/transfer-bills/transfer"
|
|
|
|
# 懒加载缓存
|
|
_private_key: RSAPrivateKey | None = None
|
|
_public_key: RSAPublicKey | None = None
|
|
|
|
|
|
class WxPayNotConfiguredError(Exception):
|
|
"""微信支付凭证 / 证书缺失,无法调用。"""
|
|
|
|
|
|
def _http_client() -> httpx.Client:
|
|
"""微信相关 HTTP 客户端。
|
|
|
|
显式使用当前 Python 环境的 certifi 证书,避免被外部 SSL_CERT_FILE 指到不存在文件。
|
|
代理仍允许从环境变量读取,方便本地开发。
|
|
"""
|
|
return httpx.Client(verify=certifi.where())
|
|
|
|
|
|
def _resolve_config_path(value: str) -> Path:
|
|
path = Path(value)
|
|
return path if path.is_absolute() else _PROJECT_ROOT / path
|
|
|
|
|
|
def _load_private_key() -> RSAPrivateKey:
|
|
global _private_key
|
|
if _private_key is None:
|
|
key_path = _resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH)
|
|
try:
|
|
with key_path.open("rb") as f:
|
|
_private_key = serialization.load_pem_private_key(f.read(), password=None)
|
|
except FileNotFoundError as e:
|
|
raise WxPayNotConfiguredError(
|
|
f"商户私钥不存在: {key_path}"
|
|
) from e
|
|
return _private_key
|
|
|
|
|
|
def _load_public_key() -> RSAPublicKey:
|
|
global _public_key
|
|
if _public_key is None:
|
|
key_path = _resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH)
|
|
try:
|
|
with key_path.open("rb") as f:
|
|
_public_key = serialization.load_pem_public_key(f.read())
|
|
except FileNotFoundError as e:
|
|
raise WxPayNotConfiguredError(
|
|
f"微信支付平台公钥不存在: {key_path}"
|
|
) from e
|
|
return _public_key
|
|
|
|
|
|
def _sign(message: str) -> str:
|
|
"""RSA-SHA256 签名,base64 输出。"""
|
|
signature = _load_private_key().sign(
|
|
message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
|
|
)
|
|
return base64.b64encode(signature).decode("utf-8")
|
|
|
|
|
|
def _build_authorization(method: str, url_path: str, body: str) -> str:
|
|
"""构建 V3 Authorization 头(WECHATPAY2-SHA256-RSA2048)。"""
|
|
timestamp = str(int(time.time()))
|
|
nonce = uuid.uuid4().hex
|
|
sign_str = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
|
|
signature = _sign(sign_str)
|
|
return (
|
|
f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.WXPAY_MCH_ID}",'
|
|
f'nonce_str="{nonce}",'
|
|
f'timestamp="{timestamp}",'
|
|
f'serial_no="{settings.WXPAY_MCH_SERIAL_NO}",'
|
|
f'signature="{signature}"'
|
|
)
|
|
|
|
|
|
def encrypt_sensitive(plain_text: str) -> str:
|
|
"""用微信支付平台公钥 OAEP(SHA1) 加密敏感信息(如实名)。"""
|
|
ciphertext = _load_public_key().encrypt(
|
|
plain_text.encode("utf-8"),
|
|
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
|
|
)
|
|
return base64.b64encode(ciphertext).decode("utf-8")
|
|
|
|
|
|
def create_transfer(
|
|
openid: str, amount_fen: int, out_bill_no: str, user_name: str | None = None
|
|
) -> dict:
|
|
"""发起商家转账到零钱。返回 {status_code, data}。
|
|
|
|
user_name(实名)在金额达微信阈值时必须传(加密)。这里沿用 elderhelper 阈值,
|
|
实际阈值以微信侧要求为准。
|
|
"""
|
|
body = {
|
|
"appid": settings.WECHAT_APP_ID,
|
|
"out_bill_no": out_bill_no,
|
|
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
|
"openid": openid,
|
|
"transfer_amount": amount_fen,
|
|
"transfer_remark": "金币提现",
|
|
"transfer_scene_report_infos": [
|
|
{"info_type": "活动名称", "info_content": "比价返现"},
|
|
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
|
|
],
|
|
}
|
|
if user_name and amount_fen >= 30:
|
|
body["user_name"] = encrypt_sensitive(user_name)
|
|
|
|
body_str = json.dumps(body, ensure_ascii=False)
|
|
headers = {
|
|
"Authorization": _build_authorization("POST", _TRANSFER_PATH, body_str),
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.post(
|
|
f"{_API_HOST}{_TRANSFER_PATH}",
|
|
content=body_str,
|
|
headers=headers,
|
|
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|
|
|
|
|
|
def query_transfer(out_bill_no: str) -> dict:
|
|
"""按商户单号查转账单。返回 {status_code, data}。"""
|
|
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}"
|
|
headers = {
|
|
"Authorization": _build_authorization("GET", path, ""),
|
|
"Accept": "application/json",
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.get(
|
|
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|
|
|
|
|
|
def cancel_transfer(out_bill_no: str) -> dict:
|
|
"""撤销转账单(仅 WAIT_USER_CONFIRM/ACCEPTED 可撤)。用户没在确认页确认就离开时调用。
|
|
成功返回 {status_code:200, data:{state:'CANCELLING'|'CANCELLED', ...}}。"""
|
|
path = f"{_TRANSFER_PATH}/out-bill-no/{out_bill_no}/cancel"
|
|
headers = {
|
|
"Authorization": _build_authorization("POST", path, ""),
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.post(
|
|
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|
|
|
|
|
|
def code_to_userinfo(code: str) -> dict:
|
|
"""code 换 access_token+openid,再拉 sns/userinfo 取昵称头像。
|
|
返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。
|
|
注意:微信隐私新政下,部分 app 的 sns/userinfo 可能返回脱敏值(昵称"微信用户"/灰头像);
|
|
nickname/avatar_url 可能为空,调用方需兜底。"""
|
|
with _http_client() as client:
|
|
r1 = client.get(
|
|
"https://api.weixin.qq.com/sns/oauth2/access_token",
|
|
params={
|
|
"appid": settings.WECHAT_APP_ID,
|
|
"secret": settings.WECHAT_APP_SECRET,
|
|
"code": code,
|
|
"grant_type": "authorization_code",
|
|
},
|
|
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
d1 = r1.json()
|
|
if "openid" not in d1 or "access_token" not in d1:
|
|
raise ValueError(f"微信授权失败: {d1.get('errmsg', d1)}")
|
|
openid = d1["openid"]
|
|
|
|
nickname = None
|
|
avatar_url = None
|
|
raw: dict = {}
|
|
# userinfo 拉取失败不应让绑定失败(openid 已拿到),吞掉异常只是没昵称头像
|
|
try:
|
|
with _http_client() as client:
|
|
r2 = client.get(
|
|
"https://api.weixin.qq.com/sns/userinfo",
|
|
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
|
|
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
raw = r2.json()
|
|
nickname = raw.get("nickname") or None
|
|
avatar_url = raw.get("headimgurl") or None
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
return {"openid": openid, "nickname": nickname, "avatar_url": avatar_url, "raw": raw}
|
|
|
|
|
|
# ===== 免确认收款授权(用户授权免确认模式)=====
|
|
# 用户授权一次后,后续转账走 transfer_with_authorization 免逐笔确认直接到账。
|
|
# 复用上面同一套 V3 签名(_build_authorization)、敏感字段加密(encrypt_sensitive)、懒加载密钥。
|
|
|
|
|
|
def _transfer_report_infos() -> list[dict]:
|
|
"""现金营销(scene 1000)转账场景报备信息,与 create_transfer 同口径。"""
|
|
return [
|
|
{"info_type": "活动名称", "info_content": "比价返现"},
|
|
{"info_type": "奖励说明", "info_content": "现金提现到微信零钱"},
|
|
]
|
|
|
|
|
|
def apply_transfer_authorization(
|
|
out_authorization_no: str,
|
|
openid: str,
|
|
user_display_name: str,
|
|
notify_url: str,
|
|
*,
|
|
scene_info: dict | None = None,
|
|
) -> dict:
|
|
"""发起免确认收款授权(方式二:不转账,仅申请授权)。返回 {status_code, data}。
|
|
成功(200 + state=WAIT_USER_CONFIRM)时 data 带 package_info,供 App 拉起微信授权页。"""
|
|
body: dict = {
|
|
"out_authorization_no": out_authorization_no,
|
|
"appid": settings.WECHAT_APP_ID,
|
|
"openid": openid,
|
|
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
|
"user_display_name": user_display_name,
|
|
"authorization_notify_url": notify_url,
|
|
}
|
|
if scene_info:
|
|
body["scene_info"] = scene_info
|
|
body_str = json.dumps(body, ensure_ascii=False)
|
|
headers = {
|
|
"Authorization": _build_authorization("POST", _AUTH_PATH, body_str),
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.post(
|
|
f"{_API_HOST}{_AUTH_PATH}",
|
|
content=body_str,
|
|
headers=headers,
|
|
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|
|
|
|
|
|
def query_transfer_authorization(out_authorization_no: str) -> dict:
|
|
"""按商户授权单号查授权结果。返回 {status_code, data}。
|
|
data.state: WAIT_USER_CONFIRM / TAKING_EFFECT(已生效) / CLOSED;TAKING_EFFECT 时带 authorization_id。"""
|
|
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}"
|
|
headers = {
|
|
"Authorization": _build_authorization("GET", path, ""),
|
|
"Accept": "application/json",
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.get(
|
|
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|
|
|
|
|
|
def close_transfer_authorization(out_authorization_no: str) -> dict:
|
|
"""解除免确认收款授权。返回 {status_code, data}(成功 data.state=CLOSED)。"""
|
|
path = f"{_AUTH_PATH}/out-authorization-no/{out_authorization_no}/close"
|
|
headers = {
|
|
"Authorization": _build_authorization("POST", path, ""),
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.post(
|
|
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|
|
|
|
|
|
def pre_transfer_with_authorization(
|
|
openid: str,
|
|
amount_fen: int,
|
|
out_bill_no: str,
|
|
out_authorization_no: str,
|
|
user_display_name: str,
|
|
notify_url: str,
|
|
user_name: str | None = None,
|
|
) -> dict:
|
|
"""方式一:发起转账并同时申请免确认收款授权(用户在确认这笔收款时一并完成授权)。
|
|
返回 {status_code, data};成功 state=WAIT_USER_CONFIRM 时带 package_info(拉确认+授权页)。"""
|
|
body: dict = {
|
|
"appid": settings.WECHAT_APP_ID,
|
|
"out_bill_no": out_bill_no,
|
|
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
|
"openid": openid,
|
|
"transfer_amount": amount_fen,
|
|
"transfer_remark": "金币提现",
|
|
"transfer_scene_report_infos": _transfer_report_infos(),
|
|
"authorization_info": {
|
|
"user_display_name": user_display_name,
|
|
"out_authorization_no": out_authorization_no,
|
|
"authorization_notify_url": notify_url,
|
|
},
|
|
}
|
|
if user_name and amount_fen >= 30:
|
|
body["user_name"] = encrypt_sensitive(user_name)
|
|
|
|
body_str = json.dumps(body, ensure_ascii=False)
|
|
headers = {
|
|
"Authorization": _build_authorization("POST", _PRE_TRANSFER_AUTH_PATH, body_str),
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.post(
|
|
f"{_API_HOST}{_PRE_TRANSFER_AUTH_PATH}",
|
|
content=body_str,
|
|
headers=headers,
|
|
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|
|
|
|
|
|
def transfer_with_authorization(
|
|
authorization_id: str,
|
|
amount_fen: int,
|
|
out_bill_no: str,
|
|
user_name: str | None = None,
|
|
) -> dict:
|
|
"""方式二 / 二次起:用户已授权后免确认转账(无需用户逐笔确认,直接到账)。返回 {status_code, data}。
|
|
成功 state ∈ ACCEPTED/PROCESSING/TRANSFERING/SUCCESS(无 WAIT_USER_CONFIRM、无 package_info)。"""
|
|
body: dict = {
|
|
"appid": settings.WECHAT_APP_ID,
|
|
"out_bill_no": out_bill_no,
|
|
"transfer_scene_id": settings.WXPAY_TRANSFER_SCENE_ID,
|
|
"transfer_amount": amount_fen,
|
|
"transfer_remark": "金币提现",
|
|
"transfer_scene_report_infos": _transfer_report_infos(),
|
|
"authorization_id": authorization_id,
|
|
}
|
|
if user_name and amount_fen >= 30:
|
|
body["user_name"] = encrypt_sensitive(user_name)
|
|
|
|
body_str = json.dumps(body, ensure_ascii=False)
|
|
headers = {
|
|
"Authorization": _build_authorization("POST", _TRANSFER_WITH_AUTH_PATH, body_str),
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
|
|
}
|
|
with _http_client() as client:
|
|
resp = client.post(
|
|
f"{_API_HOST}{_TRANSFER_WITH_AUTH_PATH}",
|
|
content=body_str,
|
|
headers=headers,
|
|
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
return {"status_code": resp.status_code, "data": resp.json()}
|