473 lines
21 KiB
Python
473 lines
21 KiB
Python
"""Small adapter around Alipay's official Python SDK.
|
||
|
||
The SDK is imported lazily so local development and the disabled/simulation
|
||
mode do not require credentials or network access.
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
from pathlib import Path
|
||
import re
|
||
|
||
from .config import settings
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AlipayTransferError(RuntimeError):
|
||
def __init__(self, message: str, code: str = "", ambiguous: bool = False):
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.ambiguous = ambiguous
|
||
|
||
|
||
def validate_configuration() -> None:
|
||
missing = [
|
||
label for label, value in (
|
||
("ALIPAY_APP_ID", settings.alipay_app_id),
|
||
("ALIPAY_PAYER_ACCOUNT", settings.alipay_payer_account),
|
||
("ALIPAY_APP_PRIVATE_KEY_PATH", settings.alipay_app_private_key_path),
|
||
("ALIPAY_PUBLIC_KEY_PATH 或 ALIPAY_ALIPAY_PUBLIC_CERT_PATH", settings.alipay_public_key_path or settings.alipay_alipay_public_cert_path),
|
||
("ALIPAY_TRANSFER_SCENE_NAME", settings.alipay_transfer_scene_name),
|
||
) if not str(value or "").strip()
|
||
]
|
||
if missing:
|
||
raise AlipayTransferError(f"支付宝配置不完整:{', '.join(missing)}")
|
||
if settings.alipay_notify_url and not settings.alipay_notify_url.lower().startswith("https://"):
|
||
raise AlipayTransferError("ALIPAY_NOTIFY_URL 必须使用公网 HTTPS 地址")
|
||
_sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
|
||
if _certificate_mode_enabled():
|
||
app_cert = _load_certificate(settings.alipay_app_cert_path, "应用公钥证书")
|
||
alipay_cert = _load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书")
|
||
root_certs = _load_certificate_bundle(settings.alipay_root_cert_path, "支付宝根证书")
|
||
_certificate_serial(app_cert)
|
||
_certificate_serial(alipay_cert)
|
||
if not root_certs:
|
||
raise AlipayTransferError("支付宝根证书文件中没有可识别的证书")
|
||
_root_certificate_serial(root_certs)
|
||
private = _sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
|
||
if not _same_rsa_key(private, _certificate_public_key(app_cert)):
|
||
raise AlipayTransferError("应用公钥证书与应用私钥不匹配")
|
||
else:
|
||
_sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
|
||
logger.info(
|
||
"Alipay provider configuration validated: scene=%s certificate_mode=%s transfer_scene_report_schema=佣金报酬说明",
|
||
settings.alipay_transfer_scene_name,
|
||
_certificate_mode_enabled(),
|
||
)
|
||
|
||
|
||
def _certificate_mode_enabled() -> bool:
|
||
return any((settings.alipay_app_cert_path, settings.alipay_alipay_public_cert_path, settings.alipay_root_cert_path))
|
||
|
||
|
||
def _load_certificate(path_value: str, label: str):
|
||
try:
|
||
from cryptography import x509
|
||
except ImportError as exc:
|
||
raise AlipayTransferError("证书模式依赖 cryptography,请安装后重启服务") from exc
|
||
if not path_value or not Path(path_value).is_file():
|
||
raise AlipayTransferError(f"{label}文件不存在,请检查对应 .env 绝对路径")
|
||
try:
|
||
return x509.load_pem_x509_certificate(Path(path_value).read_bytes())
|
||
except Exception as exc:
|
||
raise AlipayTransferError(f"{label}格式无效,应为 PEM 编码的 X.509 .crt 文件") from exc
|
||
|
||
|
||
def _load_certificate_bundle(path_value: str, label: str):
|
||
try:
|
||
from cryptography import x509
|
||
except ImportError as exc:
|
||
raise AlipayTransferError("证书模式依赖 cryptography,请安装后重启服务") from exc
|
||
if not path_value or not Path(path_value).is_file():
|
||
raise AlipayTransferError(f"{label}文件不存在,请检查对应 .env 绝对路径")
|
||
content = Path(path_value).read_bytes()
|
||
blocks = re.findall(rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", content, re.DOTALL)
|
||
certificates = []
|
||
parse_errors = []
|
||
for block in blocks:
|
||
try:
|
||
certificates.append(x509.load_pem_x509_certificate(block))
|
||
except (TypeError, ValueError) as exc:
|
||
# Alipay's root bundle contains RSA, SM2 and ECC certificates.
|
||
# Some cryptography/OpenSSL builds cannot decode the SM2
|
||
# certificate even though the RSA certificates needed for RSA2
|
||
# are valid. Parse each PEM independently so one unsupported
|
||
# non-RSA certificate does not prevent the service from starting.
|
||
parse_errors.append(exc)
|
||
if not certificates:
|
||
cause = parse_errors[0] if parse_errors else None
|
||
raise AlipayTransferError(
|
||
f"{label}格式无效,应为包含有效 RSA 证书的 PEM X.509 证书集合"
|
||
) from cause
|
||
return certificates
|
||
|
||
|
||
def _certificate_serial(certificate) -> str:
|
||
issuer = certificate.issuer.rfc4514_string()
|
||
# Match AntCertificationUtil.getCertSN in Alipay's official Java SDK:
|
||
# MD5(issuer X500 name + the certificate serial number in *decimal*).
|
||
# Using hexadecimal here makes app_cert_sn/alipay_root_cert_sn invalid.
|
||
serial = str(certificate.serial_number)
|
||
return hashlib.md5(f"{issuer}{serial}".encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _root_certificate_serial(certificates) -> str:
|
||
# Alipay's root bundle can contain RSA, SM2 and ECC certificates. The
|
||
# OpenAPI root certificate SN is defined from the RSA certificates only;
|
||
# including every certificate produces isv.invalid-alipay-root-cert-sn.
|
||
serials = [
|
||
_certificate_serial(certificate)
|
||
for certificate in certificates
|
||
# This is the exact RSA signature OID prefix check used by Alipay's
|
||
# AntCertificationUtil.getRootCertSN implementation.
|
||
if certificate.signature_algorithm_oid.dotted_string.startswith("1.2.840.113549.1.1")
|
||
]
|
||
if not serials:
|
||
raise AlipayTransferError("支付宝根证书中没有可用于计算序列号的 RSA 证书")
|
||
return "_".join(serials)
|
||
|
||
|
||
def _certificate_public_key(certificate) -> str:
|
||
from cryptography.hazmat.primitives import serialization
|
||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||
key = certificate.public_key()
|
||
if not isinstance(key, rsa.RSAPublicKey):
|
||
raise AlipayTransferError("支付宝证书必须包含 RSA 公钥")
|
||
return key.public_bytes(
|
||
encoding=serialization.Encoding.PEM,
|
||
format=serialization.PublicFormat.PKCS1,
|
||
).decode("ascii")
|
||
|
||
|
||
def _same_rsa_key(private_key: str, public_key: str) -> bool:
|
||
from Crypto.PublicKey import RSA
|
||
private = RSA.import_key(private_key)
|
||
public = RSA.import_key(public_key)
|
||
private_public = private.publickey()
|
||
return (private_public.n, private_public.e) == (public.n, public.e)
|
||
|
||
|
||
def _signed_response_content(response_value, public_key: str, charset: str = "utf-8") -> str:
|
||
"""Verify a certificate-mode response and return its API response object.
|
||
|
||
Old alipay-sdk-python releases include the top-level ``alipay_cert_sn``
|
||
field in the bytes they verify. Alipay signs only the inner ``*_response``
|
||
JSON object, so otherwise a valid code=10000 response is rejected.
|
||
"""
|
||
from alipay.aop.api.util.SignatureUtils import verify_with_rsa
|
||
|
||
response_text = response_value.decode(charset) if isinstance(response_value, bytes) else str(response_value)
|
||
marker = re.search(r'"[^"\\]+_response"\s*:\s*', response_text)
|
||
if not marker:
|
||
raise AlipayTransferError("支付宝返回内容缺少业务响应对象", ambiguous=True)
|
||
start = response_text.find("{", marker.end())
|
||
if start < 0:
|
||
raise AlipayTransferError("支付宝返回业务响应格式无效", ambiguous=True)
|
||
depth = 0
|
||
quoted = False
|
||
escaped = False
|
||
end = -1
|
||
for index in range(start, len(response_text)):
|
||
char = response_text[index]
|
||
if quoted:
|
||
if escaped:
|
||
escaped = False
|
||
elif char == "\\":
|
||
escaped = True
|
||
elif char == '"':
|
||
quoted = False
|
||
continue
|
||
if char == '"':
|
||
quoted = True
|
||
elif char == "{":
|
||
depth += 1
|
||
elif char == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
end = index + 1
|
||
break
|
||
if end < 0:
|
||
raise AlipayTransferError("支付宝返回业务响应不完整", ambiguous=True)
|
||
try:
|
||
envelope = json.loads(response_text)
|
||
except (TypeError, ValueError) as exc:
|
||
raise AlipayTransferError("支付宝返回内容无法解析", ambiguous=True) from exc
|
||
signature = str(envelope.get("sign") or "")
|
||
response_content = response_text[start:end]
|
||
if not signature or not verify_with_rsa(public_key, response_content.encode(charset), signature):
|
||
raise AlipayTransferError("支付宝响应验签失败", "ALIPAY_RESPONSE_UNVERIFIED", ambiguous=True)
|
||
return response_content
|
||
|
||
|
||
def _read_key(path_value: str, label: str) -> str:
|
||
if not path_value:
|
||
raise AlipayTransferError(f"未配置{label}文件路径")
|
||
path = Path(path_value)
|
||
if not path.is_file():
|
||
raise AlipayTransferError(f"{label}文件不存在:{path}")
|
||
value = path.read_text(encoding="utf-8-sig").strip()
|
||
if not value:
|
||
raise AlipayTransferError(f"{label}文件为空")
|
||
return value
|
||
|
||
|
||
def _sdk_key(value: str, *, private: bool) -> str:
|
||
"""Return a PKCS#1 PEM accepted by the SDK's rsa dependency."""
|
||
try:
|
||
from Crypto.PublicKey import RSA
|
||
normalized = value.strip()
|
||
if "-----BEGIN" not in normalized:
|
||
marker = "PRIVATE KEY" if private else "PUBLIC KEY"
|
||
normalized = f"-----BEGIN {marker}-----\n{normalized}\n-----END {marker}-----"
|
||
key = RSA.import_key(normalized.encode("utf-8"))
|
||
if private and not key.has_private():
|
||
raise ValueError("not a private key")
|
||
if private:
|
||
return key.export_key(format="PEM", pkcs=1).decode("ascii")
|
||
return key.publickey().export_key(format="PEM", pkcs=1).decode("ascii")
|
||
except Exception as exc:
|
||
raise AlipayTransferError("支付宝密钥格式无效,请确认上传的是完整 RSA2 密钥") from exc
|
||
|
||
|
||
def _client():
|
||
try:
|
||
from alipay.aop.api.AlipayClientConfig import AlipayClientConfig
|
||
from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient
|
||
except ImportError as exc:
|
||
raise AlipayTransferError("未安装支付宝 SDK,请安装 alipay-sdk-python") from exc
|
||
config = AlipayClientConfig()
|
||
config.server_url = settings.alipay_gateway
|
||
config.app_id = settings.alipay_app_id
|
||
config.app_private_key = _sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
|
||
if _certificate_mode_enabled():
|
||
app_cert = _load_certificate(settings.alipay_app_cert_path, "应用公钥证书")
|
||
alipay_cert = _load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书")
|
||
root_certs = _load_certificate_bundle(settings.alipay_root_cert_path, "支付宝根证书")
|
||
config.alipay_public_key = _certificate_public_key(alipay_cert)
|
||
app_cert_sn = _certificate_serial(app_cert)
|
||
alipay_root_cert_sn = _root_certificate_serial(root_certs)
|
||
|
||
class CertificateAlipayClient(DefaultAlipayClient):
|
||
def _DefaultAlipayClient__get_common_params(self, params):
|
||
common = super()._DefaultAlipayClient__get_common_params(params)
|
||
common["app_cert_sn"] = app_cert_sn
|
||
common["alipay_root_cert_sn"] = alipay_root_cert_sn
|
||
return common
|
||
|
||
def _DefaultAlipayClient__parse_response(self, response_value):
|
||
response_text = response_value.decode(settings.alipay_charset) if isinstance(response_value, bytes) else str(response_value)
|
||
if '"alipay_cert_sn"' in response_text:
|
||
return _signed_response_content(
|
||
response_text,
|
||
config.alipay_public_key,
|
||
settings.alipay_charset,
|
||
)
|
||
return super()._DefaultAlipayClient__parse_response(response_value)
|
||
|
||
client_class = CertificateAlipayClient
|
||
else:
|
||
config.alipay_public_key = _sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
|
||
client_class = DefaultAlipayClient
|
||
config.sign_type = settings.alipay_sign_type
|
||
config.charset = settings.alipay_charset
|
||
config.format = settings.alipay_format
|
||
config.timeout = settings.alipay_http_timeout_seconds
|
||
return client_class(alipay_client_config=config)
|
||
|
||
|
||
def _parse_response(raw, response_cls):
|
||
"""Parse the JSON string returned by DefaultAlipayClient.execute."""
|
||
if isinstance(raw, response_cls):
|
||
response = raw
|
||
else:
|
||
response = response_cls()
|
||
try:
|
||
response.parse_response_content(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
||
except Exception as exc:
|
||
raise AlipayTransferError("支付宝返回内容无法解析", ambiguous=True) from exc
|
||
if response.code != "10000" or response.sub_code:
|
||
raise AlipayTransferError(
|
||
response.sub_msg or response.msg or "支付宝请求失败",
|
||
response.sub_code or response.code or "ALIPAY_ERROR",
|
||
)
|
||
return response
|
||
|
||
|
||
def _provider_response_from_exception(exc: Exception) -> dict | None:
|
||
message = str(exc)
|
||
json_start = message.find("{")
|
||
if json_start < 0:
|
||
return None
|
||
try:
|
||
payload = json.loads(message[json_start:])
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return next((
|
||
value for key, value in payload.items()
|
||
if key.endswith("_response") and isinstance(value, dict)
|
||
), None)
|
||
|
||
|
||
def _unverified_success_response(exc: Exception) -> bool:
|
||
response = _provider_response_from_exception(exc)
|
||
return bool(response and str(response.get("code")) == "10000" and not response.get("sub_code"))
|
||
|
||
|
||
def _provider_error_from_exception(exc: Exception) -> AlipayTransferError | None:
|
||
"""Recover Alipay's structured business error from old SDK exceptions.
|
||
|
||
alipay-sdk-python 3.x raises ResponseException before returning an error
|
||
response when the envelope contains ``alipay_cert_sn``. The exception
|
||
still embeds the complete JSON response, so preserve the provider's
|
||
sub-code/message instead of treating a deterministic rejection as an
|
||
ambiguous network failure.
|
||
"""
|
||
response = _provider_response_from_exception(exc)
|
||
if not response or not response.get("code"):
|
||
return None
|
||
# A code=10000 envelope is not a provider rejection. The SDK may still
|
||
# raise because the response signature could not be verified; in that
|
||
# case the transfer result is uncertain and must remain pending.
|
||
if str(response.get("code")) == "10000" and not response.get("sub_code"):
|
||
return None
|
||
return AlipayTransferError(
|
||
response.get("sub_msg") or response.get("msg") or "支付宝请求失败",
|
||
response.get("sub_code") or response.get("code") or "ALIPAY_ERROR",
|
||
ambiguous=False,
|
||
)
|
||
|
||
|
||
def transfer(*, out_biz_no: str, amount_fen: int, account: str, payee_name: str, order_title: str) -> dict:
|
||
from alipay.aop.api.domain.AlipayFundTransUniTransferModel import AlipayFundTransUniTransferModel
|
||
from alipay.aop.api.domain.Participant import Participant
|
||
from alipay.aop.api.domain.TransferSceneReportInfo import TransferSceneReportInfo
|
||
from alipay.aop.api.request.AlipayFundTransUniTransferRequest import AlipayFundTransUniTransferRequest
|
||
|
||
model = AlipayFundTransUniTransferModel()
|
||
model.out_biz_no = out_biz_no
|
||
model.trans_amount = f"{Decimal(amount_fen) / Decimal(100):.2f}"
|
||
model.product_code = "TRANS_ACCOUNT_NO_PWD"
|
||
model.biz_scene = "DIRECT_TRANSFER"
|
||
model.transfer_scene_name = settings.alipay_transfer_scene_name
|
||
reward_description = TransferSceneReportInfo()
|
||
reward_description.info_type = "佣金报酬说明"
|
||
reward_description.info_content = order_title[:32]
|
||
model.transfer_scene_report_infos = [reward_description]
|
||
model.order_title = order_title[:128]
|
||
payee = Participant()
|
||
payee.identity = account
|
||
payee.identity_type = "ALIPAY_LOGON_ID"
|
||
payee.name = payee_name
|
||
model.payee_info = payee
|
||
# Keep a deploy-time diagnostic in the server log without exposing the
|
||
# recipient account, name, private key, or certificate data. This makes
|
||
# it possible to distinguish an old server build from an Alipay rejection.
|
||
logger.info(
|
||
"Alipay transfer request prepared: scene=%s info_type=%s info_content=%s amount=%s biz_scene=%s product_code=%s",
|
||
model.transfer_scene_name,
|
||
reward_description.info_type,
|
||
reward_description.info_content,
|
||
model.trans_amount,
|
||
model.biz_scene,
|
||
model.product_code,
|
||
)
|
||
try:
|
||
request = AlipayFundTransUniTransferRequest(biz_model=model)
|
||
if settings.alipay_notify_url:
|
||
request.notify_url = settings.alipay_notify_url
|
||
raw = _client().execute(request)
|
||
from alipay.aop.api.response.AlipayFundTransUniTransferResponse import AlipayFundTransUniTransferResponse
|
||
response = _parse_response(raw, AlipayFundTransUniTransferResponse)
|
||
except Exception as exc:
|
||
if isinstance(exc, AlipayTransferError):
|
||
raise
|
||
if _unverified_success_response(exc):
|
||
logger.error(
|
||
"Alipay returned a success-code envelope, but the SDK could not verify/parse it; transfer outcome is unconfirmed"
|
||
)
|
||
raise AlipayTransferError(
|
||
"支付宝返回成功格式响应,但本地未能验证响应;转账结果未确认,请查询支付宝流水,勿重复发起",
|
||
"ALIPAY_RESPONSE_UNVERIFIED",
|
||
ambiguous=True,
|
||
) from exc
|
||
provider_error = _provider_error_from_exception(exc)
|
||
if provider_error:
|
||
logger.error(
|
||
"Alipay transfer rejected: code=%s message=%s",
|
||
provider_error.code,
|
||
provider_error,
|
||
)
|
||
raise provider_error from exc
|
||
logger.exception("Alipay transfer request failed without a structured provider response")
|
||
raise AlipayTransferError(f"支付宝转账请求失败:{exc}", ambiguous=True) from exc
|
||
return {
|
||
"out_biz_no": response.out_biz_no or out_biz_no,
|
||
"trade_no": response.pay_fund_order_id or response.order_id or "",
|
||
"status": response.status or "UNKNOWN",
|
||
}
|
||
|
||
|
||
def query(*, out_biz_no: str) -> dict:
|
||
from alipay.aop.api.domain.AlipayFundTransCommonQueryModel import AlipayFundTransCommonQueryModel
|
||
from alipay.aop.api.request.AlipayFundTransCommonQueryRequest import AlipayFundTransCommonQueryRequest
|
||
|
||
model = AlipayFundTransCommonQueryModel()
|
||
model.out_biz_no = out_biz_no
|
||
model.product_code = "TRANS_ACCOUNT_NO_PWD"
|
||
model.biz_scene = "DIRECT_TRANSFER"
|
||
request = AlipayFundTransCommonQueryRequest(biz_model=model)
|
||
try:
|
||
raw = _client().execute(request)
|
||
from alipay.aop.api.response.AlipayFundTransCommonQueryResponse import AlipayFundTransCommonQueryResponse
|
||
response = _parse_response(raw, AlipayFundTransCommonQueryResponse)
|
||
except Exception as exc:
|
||
if isinstance(exc, AlipayTransferError):
|
||
raise
|
||
if _unverified_success_response(exc):
|
||
raise AlipayTransferError(
|
||
"支付宝查询返回成功格式响应,但本地未能验证响应;请稍后再次查询",
|
||
"ALIPAY_RESPONSE_UNVERIFIED",
|
||
ambiguous=True,
|
||
) from exc
|
||
provider_error = _provider_error_from_exception(exc)
|
||
if provider_error:
|
||
raise provider_error from exc
|
||
raise AlipayTransferError(f"支付宝查询请求失败:{exc}") from exc
|
||
return {
|
||
"out_biz_no": response.out_biz_no or out_biz_no,
|
||
"trade_no": response.pay_fund_order_id or response.order_id or "",
|
||
"status": response.status or response.sub_status or "",
|
||
"fail_reason": response.fail_reason or response.sub_order_fail_reason or response.sub_msg or "",
|
||
}
|
||
|
||
|
||
def verify_notification(params: dict[str, str]) -> bool:
|
||
from alipay.aop.api.util.SignatureUtils import verify_with_rsa
|
||
|
||
signature = params.get("sign")
|
||
if not signature or params.get("sign_type", "RSA2").upper() != "RSA2":
|
||
return False
|
||
if settings.alipay_app_id and params.get("app_id") not in {None, settings.alipay_app_id}:
|
||
return False
|
||
content = "&".join(
|
||
f"{key}={params[key]}" for key in sorted(params)
|
||
if key not in {"sign", "sign_type"}
|
||
)
|
||
try:
|
||
alipay_public_key = (
|
||
_certificate_public_key(_load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书"))
|
||
if _certificate_mode_enabled()
|
||
else _sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
|
||
)
|
||
return verify_with_rsa(
|
||
alipay_public_key,
|
||
content.encode(settings.alipay_charset),
|
||
signature,
|
||
)
|
||
except Exception:
|
||
return False
|