"""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 _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 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_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. """ 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 response = next(( value for key, value in payload.items() if key.endswith("_response") and isinstance(value, dict) ), None) if not response or not response.get("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 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 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