Files
server-core/app/pay/profitsharing.py
T

469 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""微信支付服务商 · 分账 / 资金托管客户端(懒加载单例)。
覆盖能力(服务商模式 partner_mode=Truemchid=服务商商户号):
- 添加分账接收方(MERCHANT_ID 子商户号 / PERSONAL_OPENID 个人微信零钱)
- 请求分账(按订单分账:OPC 收款 95% + 平台佣金 5%
- 查询分账 / 分账回退
- 分账结果回调验签解密(复用微信平台证书 + APIv3 密钥)
设计原则:
- 与 ``wxpay.py`` 直连收单客户端并存:收单继续走现有商户,分账用服务商商户号发起;
- 懒加载:缺服务商配置时 ``profitsharing_enabled()==False``,所有调用返回不可用,业务降级;
- 所有失败统一抛 RuntimeError(携带微信报文摘要),由调用方决定降级/重试。
"""
from __future__ import annotations
import json
import logging
from typing import Optional
from wechatpayv3.async_ import AsyncWeChatPay, WeChatPayType
from wechatpayv3.async_.utils import aes_decrypt
from . import config as pay_config
# 进件申请状态枚举(微信 applyment_state
APPLYMENT_STATE = {
"EDITTING": "资料编辑中",
"AUDITING": "微信审核中",
"REJECTED": "已驳回",
"TO_BE_CONFIRMED": "待账户验证(需超管扫码完成打款/法人验证)",
"TO_BE_SIGNED": "待签约(需超管扫码完成绑定+签约授权)",
"SIGNING": "开通权限中(签约后平台开通分账等产品权限)",
"FINISHED": "已完成(子商户号已下发)",
"CANCELED": "已作废",
}
# 待平台侧人工/超管介入的状态(前端应展示 sign_url 二维码引导超管扫码)
APPLYMENT_ACTION_STATES = ("TO_BE_CONFIRMED", "TO_BE_SIGNED", "SIGNING")
logger = logging.getLogger("pay.profitsharing")
_sp: Optional[AsyncWeChatPay] = None
_init_lock = False
_init_error: str = ""
async def _ensure_split_pay() -> bool:
"""确保服务商分账客户端已初始化(懒加载 + 双检锁)。"""
global _sp, _init_lock, _init_error
if _sp is not None:
return True
if not pay_config.profitsharing_enabled():
_init_error = "服务商分账配置不完整(PINEAGENTS_WX_SP_MCHID / 证书 / APIv3 / notify"
return False
if _init_lock:
import asyncio
for _ in range(50):
if _sp is not None:
return True
await asyncio.sleep(0.1)
return False
_init_lock = True
try:
with open(pay_config.WECHATPAY_SP_PRIVATE_KEY_PATH, mode="r") as f:
private_key = f.read()
import os
public_key_path = pay_config.WECHATPAY_SP_PUBLIC_KEY_PATH
# 服务商模式:mchid=服务商商户号,appid=服务商 AppID;证书/密钥用服务商独立配置
# (若未单独配置则回退直连证书,仅适用于服务商与直连为同一商户号的场景)
_sp = AsyncWeChatPay(
wechatpay_type=WeChatPayType.NATIVE,
mchid=pay_config.WECHATPAY_SP_MCHID,
private_key=private_key,
cert_serial_no=pay_config.WECHATPAY_SP_CERT_SERIAL_NO,
appid=pay_config.WECHATPAY_SP_APPID or pay_config.WECHATPAY_NATIVE_APPID,
apiv3_key=pay_config.WECHATPAY_SP_APIV3_KEY,
notify_url=pay_config.WECHATPAY_SPLIT_NOTIFY_URL,
cert_dir=pay_config.WECHATPAY_SP_CERT_DIR,
logger=logger,
partner_mode=True,
public_key=(
open(public_key_path).read()
if public_key_path and os.path.exists(public_key_path)
else None
),
public_key_id=pay_config.WECHATPAY_SP_PUBLIC_KEY_ID or None,
)
await _sp.__aenter__()
logger.info("微信服务商分账客户端懒加载初始化成功 sp_mchid=%s", pay_config.WECHATPAY_SP_MCHID)
return True
except Exception as exc: # noqa: BLE001
_init_error = str(exc)
logger.error("微信服务商分账客户端初始化失败: %s", exc, exc_info=True)
return False
finally:
_init_lock = False
def init_error() -> str:
"""最近一次初始化失败原因(供接口 503 提示)。"""
return _init_error
def _parse(data, code: int, result) -> dict:
"""统一把 wechatpayv3 返回解析为 dict;非 200 抛 RuntimeError。"""
if code != 200:
raise RuntimeError(f"微信分账接口失败 http={code}: {str(result)[:300]}")
return result if isinstance(result, dict) else json.loads(result)
# ---------------------------------------------------------------------------
# 分账接收方
# ---------------------------------------------------------------------------
async def add_receiver(*, account_type: str, account: str, name: str = "",
relation_type: str = "SERVICE_PROVIDER",
sub_mchid: str = "") -> dict:
"""添加分账接收方。
- account_type: MERCHANT_ID(商户号) | PERSONAL_OPENID(个人微信零钱)
- account: 子商户号 或 小程序 openid
- name: 个人实名(PERSONAL_OPENID 时传,微信校验实名一致)
- sub_mchid: 【服务商模式必填】分账的出资特约商户号(接收方被添加到该出资方名下)。
"""
if not await _ensure_split_pay():
raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}")
code, result = await _sp.profitsharing_add_receiver(
account_type=account_type, account=account,
relation_type=relation_type,
name=name or None,
appid=pay_config.WECHATPAY_SP_APPID or pay_config.WECHATPAY_NATIVE_APPID,
sub_mchid=sub_mchid or None,
)
return _parse(result, code, result)
# ---------------------------------------------------------------------------
# 请求分账 / 查询 / 回退
# ---------------------------------------------------------------------------
async def create_split(*, transaction_id: str, out_order_no: str,
receivers: list[dict], sub_mchid: str = "",
unfreeze_unsplit: bool = True) -> dict:
"""请求分账。
- transaction_id: 微信支付单号(订单支付成功回调带回)
- out_order_no: 平台分账单号(幂等键,PS_<ts>_<hex>
- receivers: [{type, account, amount(分), description}]
- sub_mchid: 服务商模式下收单子商户号(分账方)
- unfreeze_unsplit: True=分账同时解冻剩余;False=部分分账,后续可再分账/单独解冻
"""
if not await _ensure_split_pay():
raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}")
code, result = await _sp.profitsharing_order(
transaction_id=transaction_id,
out_order_no=out_order_no,
receivers=receivers,
unfreeze_unsplit=unfreeze_unsplit,
sub_mchid=sub_mchid or None,
)
return _parse(result, code, result)
# ---------------------------------------------------------------------------
# 分账生命周期补充:剩余待分金额 / 分账比例 / 完结分账解冻剩余
# ---------------------------------------------------------------------------
async def query_remaining_amount(*, transaction_id: str) -> dict | None:
"""查询订单剩余待分金额(分)。支付成功且订单标记 profit_sharing 后可用。
返回 {"transaction_id": ..., "unsplit_amount": int(分), "split_fee": int(分), ...}。
用于"资金冻结确认"unsplit_amount 即该订单当前可发起分账的冻结资金。
"""
if not await _ensure_split_pay():
return None
try:
code, result = await _sp.profitsharing_amount_query(transaction_id=transaction_id)
except Exception as exc: # noqa: BLE001
logger.warning("查询剩余待分金额异常 tx=%s: %s", transaction_id, exc)
return None
if code != 200:
return None
return result if isinstance(result, dict) else json.loads(result)
async def query_split_config(*, sub_mchid: str) -> dict | None:
"""查询子商户分账配置(允许服务商分账的最大比例)。
对应微信接口:GET /v3/profitsharing/merchant-configs/{sub_mchid}
返回 {"sub_mchid": ..., "max_ratio": int(万分比)}
- max_ratio 单位为万分比(2000 = 20%),为子商户在商户平台设置的允许服务商分账的最大比例,
未授权/未开通时微信返回 403 NO_AUTH(此处记 None,由调用方提示需子商户在商户平台开通分账)。
注意:该接口仅返回 max_ratio,不返回分账开关;分账是否可用以能否成功 add_receiver/查询比例为准。
"""
if not await _ensure_split_pay():
return None
try:
code, result = await _sp.profitsharing_config_query(sub_mchid=sub_mchid)
except Exception as exc: # noqa: BLE001
logger.warning("查询分账配置异常 sub_mchid=%s: %s", sub_mchid, exc)
return None
if code != 200:
return None
data = result if isinstance(result, dict) else json.loads(result)
if isinstance(data, dict) and "max_ratio" in data:
# 万分比 → 百分比(保留一位小数),便于前端展示
data["max_ratio_percent"] = round(int(data["max_ratio"]) / 100.0, 1)
return data
async def unfreeze_remaining(*, transaction_id: str, out_order_no: str,
sub_mchid: str = "", description: str = "解冻剩余资金") -> dict:
"""完结分账,解冻订单剩余未分资金回出资方商户。
对应微信接口:POST /v3/profitsharing/orders/unfreeze。
- out_order_no: 分账完结单号,**不能与请求分账时的 out_order_no 相同**(需新生成);
- 解冻后该订单不能再发起分账。
"""
if not await _ensure_split_pay():
raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}")
code, result = await _sp.profitsharing_unfreeze(
transaction_id=transaction_id, out_order_no=out_order_no,
description=description, sub_mchid=sub_mchid or None,
)
return _parse(result, code, result)
def split_finished(result: dict) -> tuple[bool, str]:
"""判断分账结果是否全部成功:state=FINISHED 且所有 receivers.result=SUCCESS。
微信分账为异步:state=FINISHED 仅代表动账执行完毕,须逐接收方看 result。
返回 (全部成功?, 失败原因摘要)。
"""
if not isinstance(result, dict):
return False, "分账结果为空"
if result.get("state") == "PROCESSING":
return False, "分账处理中,请稍后查询"
if result.get("state") != "FINISHED":
return False, f"分账未完成 state={result.get('state')}"
receivers = result.get("receivers") or []
if not receivers:
return False, "分账结果缺少接收方明细"
failed = [r.get("fail_reason") or f"result={r.get('result')}" for r in receivers
if r.get("result") != "SUCCESS"]
return (not failed), ("".join(failed) if failed else "")
# ---------------------------------------------------------------------------
# 服务商代子商户收单(partner transactions + profit_sharing 标记)
# ---------------------------------------------------------------------------
async def create_partner_order(*, client_type: str, out_trade_no: str, total_fen: int,
description: str, sub_mchid: str,
payer_sp_openid: str = "",
notify_url: str = "", profit_sharing: bool = True) -> dict:
"""服务商代子商户收单(资金直接进子商户账户并可分账)。
对应微信接口(partner transactions 系列):
- native: POST /v3/pay/partner/transactions/native → {code_url}
- jsapi : POST /v3/pay/partner/transactions/jsapi → {pay_params}(需 payer_sp_openid
关键:profit_sharing=True 时订单带分账标记,支付成功后资金在子商户账户冻结,可分账。
"""
if not await _ensure_split_pay():
raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}")
if client_type == "jsapi" and not payer_sp_openid:
raise RuntimeError("JSAPI 收单缺少用户 openidpayer_sp_openid")
notify = notify_url or pay_config.WECHATPAY_SPLIT_NOTIFY_URL
appid = pay_config.WECHATPAY_SP_APPID or pay_config.WECHATPAY_NATIVE_APPID
pay_type = WeChatPayType.MINIPROG if client_type == "jsapi" else WeChatPayType.NATIVE
payer = {"sp_openid": payer_sp_openid} if client_type == "jsapi" else None
settle_info = {"profit_sharing": True} if profit_sharing else None
try:
code, result = await _sp.pay(
description=description, out_trade_no=out_trade_no,
amount={"total": int(total_fen), "currency": "CNY"},
payer=payer, pay_type=pay_type, appid=appid,
sub_mchid=sub_mchid, settle_info=settle_info,
notify_url=notify,
)
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"微信服务商收单失败: {str(exc)[:300]}") from exc
if code != 200:
raise RuntimeError(f"微信服务商收单失败 http={code}: {str(result)[:300]}")
data = result if isinstance(result, dict) else json.loads(result)
if client_type == "jsapi":
prepay_id = data.get("prepay_id") or ""
if not prepay_id:
raise RuntimeError(f"微信服务商收单无 prepay_id: {str(data)[:300]}")
time_stamp = str(int(__import__("time").time()))
import uuid
nonce_str = uuid.uuid4().hex
package = f"prepay_id={prepay_id}"
# 服务商模式:签名主体为服务商商户私钥 + 服务商 AppID
pay_sign = _sp.sign([appid, time_stamp, nonce_str, package])
return {"appid": appid, "prepay_id": prepay_id,
"pay_params": {"timeStamp": time_stamp, "nonceStr": nonce_str,
"package": package, "signType": "RSA", "paySign": pay_sign}}
code_url = data.get("code_url") or ""
if not code_url:
raise RuntimeError(f"微信服务商收单无 code_url: {str(data)[:300]}")
return {"appid": appid, "code_url": code_url}
# ---------------------------------------------------------------------------
# 结算账户查询/修改(子商户进件后对公/法人结算账户管理)
# ---------------------------------------------------------------------------
async def query_settlement(*, sub_mchid: str) -> dict | None:
"""查询子商户结算账户(开户行/账号脱敏/结算类型等)。"""
if not await _ensure_split_pay():
return None
try:
code, result = await _sp.applyment_settlement_query(sub_mchid=sub_mchid)
except Exception as exc: # noqa: BLE001
logger.warning("查询结算账户异常 sub_mchid=%s: %s", sub_mchid, exc)
return None
if code != 200:
return None
return result if isinstance(result, dict) else json.loads(result)
async def query_split(*, transaction_id: str, out_order_no: str,
sub_mchid: str = "") -> Optional[dict]:
"""查询分账结果;无结果返回 None。"""
if not await _ensure_split_pay():
return None
try:
code, result = await _sp.profitsharing_order_query(
transaction_id=transaction_id, out_order_no=out_order_no,
sub_mchid=sub_mchid or None,
)
except Exception as exc: # noqa: BLE001
logger.warning("查询分账异常 order=%s: %s", out_order_no, exc)
return None
if code != 200:
return None
return result if isinstance(result, dict) else json.loads(result)
async def split_return(*, out_order_no: str, return_mchid: str, amount: int,
description: str = "分账回退", sub_mchid: str = "") -> dict:
"""分账回退(退款/纠错场景)。"""
if not await _ensure_split_pay():
raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}")
out_return_no = f"RT_{out_order_no}_{int(__import__('time').time())}"
code, result = await _sp.profitsharing_return(
out_return_no=out_return_no, return_mchid=return_mchid, amount=amount,
description=description, out_order_no=out_order_no, sub_mchid=sub_mchid or None,
)
return _parse(result, code, result)
# ---------------------------------------------------------------------------
# 特约商户进件(applyment4sub:OPC 商户绑定前置,微信审核 1–3 工作日)
# ---------------------------------------------------------------------------
async def submit_applyment(*, business_code: str, contact_info: dict,
subject_info: dict, business_info: dict,
settlement_info: dict, bank_account_info: dict,
addition_info: dict | None = None) -> dict:
"""提交特约商户进件申请单。
敏感字段(联系人姓名/证件/手机/邮箱、法人、银行账户)由 wechatpayv3 内部
使用服务商商户 API 证书公钥加密后传输,不落平台明文。
返回 {applyment_id, applyment_state, ...};进件为异步审核(1–3 工作日)。
"""
if not await _ensure_split_pay():
raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}")
code, result = await _sp.applyment_submit(
business_code=business_code,
contact_info=contact_info or None,
subject_info=subject_info or None,
business_info=business_info or None,
settlement_info=settlement_info or None,
bank_account_info=bank_account_info or None,
addition_info=addition_info or None,
)
return _parse(result, code, result)
def normalize_applyment_state(state: str) -> str:
"""把微信 applyment_state 规范化为裸枚举(去掉 APPLYMENT_STATE_ 前缀)。
官方查询接口返回形如 ``APPLYMENT_STATE_FINISHED``SDK 原样透传不剥前缀。
统一转成裸枚举(FINISHED / TO_BE_SIGNED / ...),供业务层与前端一致使用。
兼容已剥前缀的返回值。
"""
if not state:
return state
if state.startswith("APPLYMENT_STATE_"):
return state[len("APPLYMENT_STATE_"):]
return state
async def query_applyment(*, business_code: str = "", applyment_id: str = "") -> dict | None:
"""查询特约商户进件申请状态;失败返回 None。
applyment_state 枚举(见模块头 APPLYMENT_STATE,已规范化去掉前缀):
EDITTING / AUDITING / REJECTED / TO_BE_CONFIRMED(待账户验证) /
TO_BE_SIGNED(待签约) / SIGNING(开通权限中) / FINISHED / CANCELED。
关键返回字段:
- sign_url:超级管理员签约链接(查询即返回),超管用微信扫码→关注"微信支付商家助手"
→根据公众号指引完成【核对联系信息 + 账户验证(打款/法人) + 签约授权】;
- account_validation:账户验证信息(打款验证收款账户、金额区间、验证截止时间);
- sub_mchidTO_BE_SIGNED / SIGNING / FINISHED 时返回(特约商户号);
- audit_detailREJECTED 时返回 [{field, field_name, reject_reason}](驳回原因详情)。
"""
if not await _ensure_split_pay():
return None
try:
if business_code:
code, result = await _sp.applyment_query(business_code=business_code)
elif applyment_id:
code, result = await _sp.applyment_query(applyment_id=applyment_id)
else:
return None
except Exception as exc: # noqa: BLE001
logger.warning("查询进件异常 applyment_id=%s: %s", applyment_id, exc)
return None
if code != 200:
return None
data = result if isinstance(result, dict) else json.loads(result)
if isinstance(data, dict) and data.get("applyment_state"):
data["applyment_state"] = normalize_applyment_state(data["applyment_state"])
return data
async def upload_image(*, filepath: str, filename: str = "") -> dict:
"""上传进件素材(营业执照/法人证件/银行账户证明等)→ 返回微信 media_id。
微信素材 media_id 有效期为 3 天,需在提交进件前上传并立即使用。
"""
if not await _ensure_split_pay():
raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}")
code, result = await _sp.image_upload(filepath=filepath, filename=filename or None)
if code != 200:
raise RuntimeError(f"微信素材上传失败 http={code}: {str(result)[:300]}")
return result if isinstance(result, dict) else json.loads(result)
# ---------------------------------------------------------------------------
# 分账结果回调验签 + 解密(复用同一平台证书 / APIv3 密钥体系)
# ---------------------------------------------------------------------------
async def verify_split_notify(headers, body) -> Optional[dict]:
"""验证微信分账回调签名并解密 resource;失败返回 None。
分账回调与支付回调同用微信平台证书 + APIv3 密钥;若直连客户端已初始化,
直接复用 wxpay.verify_and_decrypt,否则用服务商客户端验签。
"""
from . import wxpay
if await _ensure_split_pay():
body_str = body.decode("UTF-8") if isinstance(body, bytes) else body
if await _sp._core._verify_signature_async(headers, body_str):
data = json.loads(body_str)
resource = data.get("resource")
if not resource:
logger.error("分账回调缺少 resource 字段")
return None
try:
decrypted = aes_decrypt(
nonce=resource.get("nonce"),
ciphertext=resource.get("ciphertext"),
associated_data=resource.get("associated_data") or "",
apiv3_key=_sp._core._apiv3_key,
)
except Exception as exc: # noqa: BLE001
logger.error("分账回调解密异常: %s", exc)
return None
if not decrypted:
return None
data.update({"resource": json.loads(decrypted)})
return data
# 回退:直连客户端验签
return await wxpay.verify_and_decrypt(headers, body)