add
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""支付适配层(《02-技术文档》6.5):三渠道统一产出 PaymentResult 交 settle 入账"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
"""支付宝渠道:App 支付下单参数(本地 RSA2 签名)+ 回调 RSA2 验签。
|
||||
|
||||
密钥未配置(开发环境)时:下单返回 dev_mock 占位参数便于 App 联调,回调一律验签失败拒绝入账。
|
||||
生产接入:配置 ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY(应用私钥)/ ALIPAY_PUBLIC_KEY(支付宝公钥)
|
||||
及 ALIPAY_NOTIFY_URL 后自动启用真实签名与验签。
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.config import settings
|
||||
from app.payment.base import build_sign_content, load_public_key, rsa2_sign, rsa2_verify
|
||||
|
||||
METHOD_APP_PAY = "alipay.trade.app.pay"
|
||||
PRODUCT_CODE = "QUICK_MSECURITY_PAY"
|
||||
DEFAULT_SUBJECT = "积分充值"
|
||||
ORDER_EXPIRE = "30m" # 与订单有效期保持一致
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(settings.alipay_app_id and settings.alipay_private_key and settings.alipay_public_key)
|
||||
|
||||
|
||||
def create_app_pay_params(order_no: str, amount_fen: int) -> dict:
|
||||
"""生成 App SDK 调起参数 order_string(未配置密钥时返回 dev 占位)"""
|
||||
if not is_configured():
|
||||
return {"dev_mock": True, "order_string": f"mock://alipay/{order_no}"}
|
||||
|
||||
biz_content = json.dumps({
|
||||
"out_trade_no": order_no,
|
||||
"total_amount": f"{amount_fen / 100:.2f}",
|
||||
"subject": DEFAULT_SUBJECT,
|
||||
"product_code": PRODUCT_CODE,
|
||||
"timeout_express": ORDER_EXPIRE,
|
||||
}, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
params = {
|
||||
"app_id": settings.alipay_app_id,
|
||||
"method": METHOD_APP_PAY,
|
||||
"format": "JSON",
|
||||
"charset": "utf-8",
|
||||
"sign_type": "RSA2",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"version": "1.0",
|
||||
"biz_content": biz_content,
|
||||
}
|
||||
if settings.alipay_notify_url:
|
||||
params["notify_url"] = settings.alipay_notify_url
|
||||
if settings.alipay_return_url:
|
||||
params["return_url"] = settings.alipay_return_url
|
||||
|
||||
# 签名串不含 sign_type/sign,按 key 字典序拼接
|
||||
params["sign"] = rsa2_sign(build_sign_content(params), settings.alipay_private_key)
|
||||
# order_string 为全参数(含 sign)按 key 字典序 URL 编码后的查询串
|
||||
return {"order_string": urlencode(sorted(params.items()))}
|
||||
|
||||
|
||||
def verify_callback(form: dict) -> bool:
|
||||
"""RSA2 验签:用支付宝公钥校验回调 sign(未配置/格式错误一律拒绝)"""
|
||||
sign = form.get("sign", "")
|
||||
if not sign or not settings.alipay_public_key:
|
||||
return False
|
||||
try:
|
||||
load_public_key(settings.alipay_public_key)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return rsa2_verify(build_sign_content(form), sign, settings.alipay_public_key)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""支付适配层公共能力:
|
||||
|
||||
- 密钥加载与 PEM 规整(兼容支付宝常见的「无头无尾裸 base64」私钥/公钥)
|
||||
- RSA2(SHA256withRSA) 签名与验签(支付宝 RSA2 / 微信 V3 共用同一原语)
|
||||
- 微信 V3 回调 AES-256-GCM 解密
|
||||
|
||||
三渠道统一把解析结果交给 payment_service.settle_order 幂等入账。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
|
||||
class PaymentConfigError(Exception):
|
||||
"""支付渠道密钥/参数不可用(配置缺失或格式错误)"""
|
||||
|
||||
|
||||
# ---------- 密钥加载 ----------
|
||||
|
||||
def _normalize_pem(key: str, label: str) -> bytes:
|
||||
"""把裸 base64 或带换行内容规整为标准 PEM;已带头尾则原样返回"""
|
||||
key = (key or "").strip()
|
||||
if not key:
|
||||
raise PaymentConfigError(f"{label} 未配置")
|
||||
if key.startswith("-----BEGIN"):
|
||||
return key.encode()
|
||||
body = "".join(key.split())
|
||||
chunks = "\n".join(body[i:i + 64] for i in range(0, len(body), 64))
|
||||
return f"-----BEGIN {label}-----\n{chunks}\n-----END {label}-----\n".encode()
|
||||
|
||||
|
||||
def load_private_key(key: str) -> rsa.RSAPrivateKey:
|
||||
pem = _normalize_pem(key, "RSA PRIVATE KEY")
|
||||
try:
|
||||
loaded = serialization.load_pem_private_key(pem, password=None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 支付宝密钥常见为 PKCS8,裸 base64 时按 PKCS8 再试一次
|
||||
try:
|
||||
pem8 = _normalize_pem(key, "PRIVATE KEY")
|
||||
loaded = serialization.load_pem_private_key(pem8, password=None)
|
||||
except Exception as exc2: # noqa: BLE001
|
||||
raise PaymentConfigError(f"私钥格式错误: {exc2}") from exc
|
||||
if not isinstance(loaded, rsa.RSAPrivateKey):
|
||||
raise PaymentConfigError("私钥不是 RSA 类型")
|
||||
return loaded
|
||||
|
||||
|
||||
def load_public_key(key: str) -> rsa.RSAPublicKey:
|
||||
pem = _normalize_pem(key, "PUBLIC KEY")
|
||||
try:
|
||||
loaded = serialization.load_pem_public_key(pem)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
try:
|
||||
pem_cert = _normalize_pem(key, "CERTIFICATE")
|
||||
loaded = serialization.load_pem_public_key(pem_cert)
|
||||
except Exception as exc2: # noqa: BLE001
|
||||
raise PaymentConfigError(f"公钥格式错误: {exc2}") from exc
|
||||
if not isinstance(loaded, rsa.RSAPublicKey):
|
||||
raise PaymentConfigError("公钥不是 RSA 类型")
|
||||
return loaded
|
||||
|
||||
|
||||
# ---------- RSA2 签名 / 验签 ----------
|
||||
|
||||
def rsa2_sign(content: str, private_key: str) -> str:
|
||||
"""SHA256withRSA 签名,返回 base64"""
|
||||
key = load_private_key(private_key)
|
||||
signature = key.sign(content.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
|
||||
return base64.b64encode(signature).decode()
|
||||
|
||||
|
||||
def rsa2_verify(content: str, signature_b64: str, public_key: str) -> bool:
|
||||
"""SHA256withRSA 验签;内容/签名异常一律返回 False(不抛错,交由调用方拒绝入账)"""
|
||||
try:
|
||||
key = load_public_key(public_key)
|
||||
key.verify(
|
||||
base64.b64decode(signature_b64),
|
||||
content.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def build_sign_content(params: dict) -> str:
|
||||
"""待验签/待签名串:剔除 sign/sign_type 与空值,按 key 字典序,k=v 以 & 连接"""
|
||||
items = sorted(
|
||||
(k, v) for k, v in params.items()
|
||||
if k not in ("sign", "sign_type") and v not in (None, "")
|
||||
)
|
||||
return "&".join(f"{k}={v}" for k, v in items)
|
||||
|
||||
|
||||
# ---------- 微信 V3 回调解密 ----------
|
||||
|
||||
def wechat_decrypt_resource(resource: dict, api_v3_key: str) -> dict | None:
|
||||
"""AES-256-GCM 解密回调 resource,返回业务明文 dict;失败返回 None"""
|
||||
try:
|
||||
ciphertext = base64.b64decode(resource.get("ciphertext", ""))
|
||||
nonce = resource.get("nonce", "").encode("utf-8")
|
||||
associated = (resource.get("associated_data") or "").encode("utf-8")
|
||||
if not ciphertext or not nonce:
|
||||
return None
|
||||
aesgcm = AESGCM(api_v3_key.encode("utf-8"))
|
||||
plain = aesgcm.decrypt(nonce, ciphertext, associated)
|
||||
return json.loads(plain.decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
@@ -0,0 +1,16 @@
|
||||
"""USDT (TRC20) 渠道:唯一金额生成。
|
||||
|
||||
USDT 无支付平台回调,到账依赖服务端轮询 TronGrid(每 15s):
|
||||
匹配条件 = 收款地址一致 + 金额(含尾差)精确相等 + 转账时间在订单有效期内 + 确认数 >= 19;
|
||||
匹配成功即入账(tx_hash 写入 third_trade_no,唯一索引保证幂等)。
|
||||
当前以 Celery worker 部署后实现轮询任务;开发期可用后台"人工补单"入账。
|
||||
"""
|
||||
import random
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
|
||||
def gen_usdt_amount(amount_yuan: int, rate: Decimal) -> Decimal:
|
||||
"""base(元/汇率) + 随机尾差(0.001~0.999),保留 3 位小数;有效期内金额唯一用于订单匹配"""
|
||||
base = (Decimal(amount_yuan) / rate).quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
|
||||
tail = Decimal(random.randint(1, 999)) / Decimal(1000)
|
||||
return base + tail
|
||||
@@ -0,0 +1,111 @@
|
||||
"""微信支付渠道(V3):App 下单(服务端签名) + 回调验签/AES-GCM 解密。
|
||||
|
||||
密钥未配置(开发环境)时:下单返回 dev_mock 占位,回调一律拒绝入账。
|
||||
生产接入需配置:WECHAT_APP_ID / WECHAT_MCHID / WECHAT_API_V3_KEY / WECHAT_PRIVATE_KEY(商户 API 私钥)
|
||||
/ WECHAT_CERT_SERIAL_NO(商户证书序列号)/ WECHAT_PLATFORM_CERT(微信支付平台证书 PEM)/ WECHAT_NOTIFY_URL。
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from app.config import settings
|
||||
from app.payment.base import PaymentConfigError, rsa2_sign, rsa2_verify, wechat_decrypt_resource
|
||||
|
||||
APP_PATH = "/v3/pay/transactions/app"
|
||||
APP_PACKAGE = "Sign=WXPay"
|
||||
DEFAULT_SUBJECT = "积分充值"
|
||||
HTTP_TIMEOUT = 10
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return bool(
|
||||
settings.wechat_mchid and settings.wechat_app_id
|
||||
and settings.wechat_api_v3_key and settings.wechat_private_key
|
||||
)
|
||||
|
||||
|
||||
def _authorization(method: str, url_path: str, body: str) -> str:
|
||||
"""构造 V3 请求签名的 Authorization 头(WECHATPAY2-SHA256-RSA2048)"""
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
message = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
signature = rsa2_sign(message, settings.wechat_private_key)
|
||||
return (
|
||||
f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.wechat_mchid}",'
|
||||
f'nonce_str="{nonce}",signature="{signature}",'
|
||||
f'timestamp="{timestamp}",serial_no="{settings.wechat_cert_serial_no}"'
|
||||
)
|
||||
|
||||
|
||||
async def _create_prepay_id(order_no: str, amount_fen: int) -> str:
|
||||
"""V3 下单(App 支付)→ prepay_id"""
|
||||
import httpx # 延迟导入:未配置渠道时无需依赖
|
||||
|
||||
body = json.dumps({
|
||||
"appid": settings.wechat_app_id,
|
||||
"mchid": settings.wechat_mchid,
|
||||
"description": DEFAULT_SUBJECT,
|
||||
"out_trade_no": order_no,
|
||||
"notify_url": settings.wechat_notify_url,
|
||||
"amount": {"total": amount_fen, "currency": "CNY"},
|
||||
}, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
headers = {
|
||||
"Authorization": _authorization("POST", APP_PATH, body),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "video-server/1.0",
|
||||
}
|
||||
async with httpx.AsyncClient(base_url=settings.wechat_api_base, timeout=HTTP_TIMEOUT) as client:
|
||||
resp = await client.post(APP_PATH, content=body.encode("utf-8"), headers=headers)
|
||||
if resp.status_code != 200:
|
||||
raise PaymentConfigError(f"微信下单失败: {resp.status_code} {resp.text[:200]}")
|
||||
prepay_id = (resp.json() or {}).get("prepay_id") or ""
|
||||
if not prepay_id:
|
||||
raise PaymentConfigError("微信下单未返回 prepay_id")
|
||||
return prepay_id
|
||||
|
||||
|
||||
async def create_app_pay_params(order_no: str, amount_fen: int) -> dict:
|
||||
"""生成 App SDK 调起参数(未配置密钥时返回 dev 占位)"""
|
||||
if not is_configured():
|
||||
return {"dev_mock": True, "prepayid": f"mock-{order_no}"}
|
||||
|
||||
prepay_id = await _create_prepay_id(order_no, amount_fen)
|
||||
# App 调起需二次签名(不同于接口签名串)
|
||||
timestamp = str(int(time.time()))
|
||||
noncestr = uuid.uuid4().hex
|
||||
sign_str = (
|
||||
f"{settings.wechat_app_id}\n{settings.wechat_mchid}\n{prepay_id}\n"
|
||||
f"{APP_PACKAGE}\n{noncestr}\n{timestamp}\n"
|
||||
)
|
||||
return {
|
||||
"appid": settings.wechat_app_id,
|
||||
"partnerid": settings.wechat_mchid,
|
||||
"prepayid": prepay_id,
|
||||
"package": APP_PACKAGE,
|
||||
"noncestr": noncestr,
|
||||
"timestamp": timestamp,
|
||||
"sign": rsa2_sign(sign_str, settings.wechat_private_key),
|
||||
}
|
||||
|
||||
|
||||
def verify_callback(headers: dict, body: bytes) -> dict | None:
|
||||
"""V3 回调验签 + AES-GCM 解密,成功返回明文报文 dict,失败返回 None(未配置一律 None)"""
|
||||
if not is_configured() or not settings.wechat_platform_cert:
|
||||
return None
|
||||
timestamp = headers.get("wechatpay-timestamp", "")
|
||||
nonce = headers.get("wechatpay-nonce", "")
|
||||
signature = headers.get("wechatpay-signature", "")
|
||||
if not (timestamp and nonce and signature):
|
||||
return None
|
||||
# 验签串:timestamp\nnonce\nbody\n(body 为原始报文,勿用解析后结果)
|
||||
message = f"{timestamp}\n{nonce}\n{body.decode('utf-8')}\n"
|
||||
if not rsa2_verify(message, signature, settings.wechat_platform_cert):
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
resource = payload.get("resource") or {}
|
||||
return wechat_decrypt_resource(resource, settings.wechat_api_v3_key)
|
||||
Reference in New Issue
Block a user