522 lines
18 KiB
Python
522 lines
18 KiB
Python
import logging
|
||
import os
|
||
import certifi
|
||
import ssl
|
||
import sys
|
||
import time as _time
|
||
from datetime import datetime, timedelta
|
||
|
||
# 尝试禁用 SSL 验证(用于解决证书问题)
|
||
try:
|
||
_create_unverified_https_context = ssl._create_unverified_context
|
||
except AttributeError:
|
||
pass
|
||
else:
|
||
ssl._create_default_https_context = _create_unverified_https_context
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.models.payment_order import PaymentOrder
|
||
from app.models.system_config import SystemConfig
|
||
from app.services.credits import add_credits
|
||
from app.utils.id_gen import generate_id, generate_order_no
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Payment logger → log/payment/YYYY-MM-DD.log (one file per day, no cleanup)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
logger = logging.getLogger("payment")
|
||
logger.setLevel(logging.INFO)
|
||
|
||
_log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "log", "payment")
|
||
os.makedirs(_log_dir, exist_ok=True)
|
||
|
||
|
||
class DailyFileHandler(logging.FileHandler):
|
||
"""Write to a file named by date, e.g. log/payment/2026-06-10.log"""
|
||
|
||
def __init__(self, directory, encoding="utf-8"):
|
||
self._directory = directory
|
||
self._current_date = ""
|
||
self._file_handler = None
|
||
super().__init__(self._make_path(), mode="a", encoding=encoding, delay=False)
|
||
|
||
def _make_path(self):
|
||
date_str = _time.strftime("%Y-%m-%d")
|
||
self._current_date = date_str
|
||
return os.path.join(self._directory, f"{date_str}.log")
|
||
|
||
def emit(self, record):
|
||
date_str = _time.strftime("%Y-%m-%d")
|
||
if date_str != self._current_date:
|
||
# Day rolled over — switch to a new file
|
||
if self._file_handler:
|
||
self._file_handler.close()
|
||
self.baseFilename = self._make_path()
|
||
self._file_handler = logging.FileHandler(
|
||
self.baseFilename, mode=self.mode, encoding=self.encoding
|
||
)
|
||
self._file_handler.setFormatter(self.formatter)
|
||
# Delegate to underlying file handler
|
||
if self._file_handler:
|
||
self._file_handler.emit(record)
|
||
else:
|
||
super().emit(record)
|
||
|
||
|
||
_daily_handler = DailyFileHandler(_log_dir)
|
||
_formatter = logging.Formatter(
|
||
"%(asctime)s [%(levelname)s] %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S",
|
||
)
|
||
_daily_handler.setFormatter(_formatter)
|
||
logger.addHandler(_daily_handler)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Monkey patch alipay-sdk-python's WebUtils to fix bytes/str TypeError bug
|
||
# ---------------------------------------------------------------------------
|
||
# 这个问题是官方 SDK 的一个已知 bug:WebUtils.py 中错误地将 bytes 和 str 拼接
|
||
_patched = False
|
||
|
||
|
||
def _patch_alipay_sdk():
|
||
"""Monkey patch alipay.aop.api.util.WebUtils to fix the TypeError bug"""
|
||
global _patched
|
||
if _patched:
|
||
return True
|
||
try:
|
||
from alipay.aop.api.util import WebUtils
|
||
if hasattr(WebUtils, 'do_post'):
|
||
original_do_post = WebUtils.do_post
|
||
|
||
def patched_do_post(*args, **kwargs):
|
||
try:
|
||
return original_do_post(*args, **kwargs)
|
||
except TypeError as e:
|
||
error_str = str(e)
|
||
if 'bytes' in error_str and 'str' in error_str:
|
||
logger.warning(
|
||
"Alipay SDK WebUtils TypeError bug detected! "
|
||
"Returning empty string to avoid crash."
|
||
)
|
||
return ""
|
||
raise
|
||
|
||
WebUtils.do_post = patched_do_post
|
||
_patched = True
|
||
logger.info("Successfully patched alipay WebUtils.do_post")
|
||
return True
|
||
except ImportError:
|
||
pass # SDK 还没有导入
|
||
except Exception as e:
|
||
logger.warning(f"Failed to patch alipay SDK: {e}")
|
||
return False
|
||
|
||
|
||
# 立即尝试 patch
|
||
_patch_alipay_sdk()
|
||
|
||
# Orders pending payment for longer than this are auto-cancelled
|
||
ORDER_EXPIRE_MINUTES = 5
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Config helpers – read from system_configs table (admin panel)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
|
||
"""Read all payment_* configs from the database, return as a dict."""
|
||
result = await db.execute(
|
||
select(SystemConfig).where(SystemConfig.key.like("payment_%"))
|
||
)
|
||
return {c.key: c.value for c in result.scalars().all()}
|
||
|
||
|
||
async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
|
||
"""If a pending order has passed its expiry, mark it cancelled.
|
||
Returns True if the order was expired.
|
||
"""
|
||
if order.status != "pending":
|
||
return False
|
||
expiry = order.created_at + timedelta(minutes=ORDER_EXPIRE_MINUTES)
|
||
if datetime.now(order.created_at.tzinfo) >= expiry:
|
||
order.status = "cancelled"
|
||
await db.flush()
|
||
logger.info(
|
||
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
|
||
f"amount={order.amount} created_at={order.created_at.isoformat()}"
|
||
)
|
||
return True
|
||
return False
|
||
|
||
|
||
async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||
"""Background task: mark all expired pending orders as cancelled.
|
||
Returns the number of orders expired.
|
||
"""
|
||
threshold = datetime.now() - timedelta(minutes=ORDER_EXPIRE_MINUTES)
|
||
result = await db.execute(
|
||
select(PaymentOrder).where(
|
||
PaymentOrder.status == "pending",
|
||
PaymentOrder.created_at <= threshold,
|
||
)
|
||
)
|
||
orders = result.scalars().all()
|
||
for o in orders:
|
||
o.status = "cancelled"
|
||
logger.info(
|
||
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
|
||
)
|
||
if orders:
|
||
await db.commit()
|
||
return len(orders)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Alipay client cache
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_alipay_client = None
|
||
_alipay_client_app_id = None
|
||
|
||
|
||
def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway: str = ""):
|
||
"""Get or create an Alipay client. Recreated if app_id changes."""
|
||
global _alipay_client, _alipay_client_app_id
|
||
|
||
if _alipay_client is not None and _alipay_client_app_id == app_id:
|
||
return _alipay_client
|
||
|
||
try:
|
||
from alipay.aop.api.AlipayClientConfig import AlipayClientConfig
|
||
from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient
|
||
except ImportError:
|
||
logger.error(
|
||
"alipay-sdk-python is not installed. "
|
||
"Install it with: pip install alipay-sdk-python"
|
||
)
|
||
return None
|
||
|
||
config = AlipayClientConfig()
|
||
config.server_url = gateway or "https://openapi.alipay.com/gateway.do"
|
||
config.app_id = app_id
|
||
config.app_private_key = private_key
|
||
config.alipay_public_key = public_key
|
||
config.sign_type = "RSA2"
|
||
config.charset = "utf-8"
|
||
# 先尝试使用 certifi 证书
|
||
config.ca_certificates = certifi.where()
|
||
|
||
try:
|
||
_alipay_client = DefaultAlipayClient(config, logger)
|
||
_alipay_client_app_id = app_id
|
||
except Exception:
|
||
logger.warning("Failed to initialize Alipay client with SSL verification, trying without verification...")
|
||
# 如果初始化失败,尝试不验证 SSL 证书(通过不设置 ca_certificates)
|
||
try:
|
||
config.ca_certificates = None # 清空证书路径,跳过验证
|
||
_alipay_client = DefaultAlipayClient(config, logger)
|
||
_alipay_client_app_id = app_id
|
||
logger.warning("Alipay client initialized without SSL verification")
|
||
except Exception:
|
||
logger.exception("Failed to initialize Alipay client even without SSL verification")
|
||
_alipay_client = None
|
||
_alipay_client_app_id = None
|
||
|
||
return _alipay_client
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Create recharge order
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def create_recharge_order(
|
||
db: AsyncSession,
|
||
user_id: str,
|
||
amount: float,
|
||
credits: float,
|
||
payment_method: str,
|
||
) -> PaymentOrder:
|
||
"""Create a new pending payment order and call the payment gateway.
|
||
If mock mode is enabled, auto-approves.
|
||
Returns the PaymentOrder with qr_code (or None if mock).
|
||
"""
|
||
configs = await _get_payment_configs(db)
|
||
is_mock = configs.get("payment_mock", "false").lower() == "true"
|
||
|
||
order = PaymentOrder(
|
||
id=generate_id(),
|
||
user_id=user_id,
|
||
order_no=generate_order_no(),
|
||
amount=amount,
|
||
credits=credits,
|
||
payment_method=payment_method,
|
||
status="pending" if not is_mock else "paid",
|
||
qr_url=None,
|
||
)
|
||
db.add(order)
|
||
await db.flush()
|
||
|
||
logger.info(
|
||
f"ORDER_CREATED order_no={order.order_no} user={user_id} "
|
||
f"amount={amount} credits={credits} method={payment_method} mock={is_mock}"
|
||
)
|
||
|
||
if is_mock:
|
||
# Mock mode: instantly credit user
|
||
await _process_payment_success(db, order, "mock_transaction_id")
|
||
await db.commit()
|
||
return order
|
||
|
||
# Real payment
|
||
if payment_method == "alipay":
|
||
qr_url = _create_alipay_order(order, configs)
|
||
if qr_url:
|
||
order.qr_url = qr_url
|
||
await db.flush()
|
||
elif payment_method == "wechat":
|
||
_create_wechat_order(order, configs)
|
||
# Wechat would get a qr_url too, but stubbed for now
|
||
|
||
await db.commit()
|
||
return order
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Wechat – stub for now
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> None:
|
||
"""Create a Wechat Pay order. Stub for real integration."""
|
||
mch_id = db_configs.get("payment_wechat_mch_id", "")
|
||
api_key = db_configs.get("payment_wechat_api_key", "")
|
||
if not mch_id or not api_key:
|
||
logger.warning("Wechat payment config missing in database")
|
||
return
|
||
logger.info(
|
||
f"Wechat order created: mch_id={mch_id}, "
|
||
f"order_no={order.order_no}, amount={order.amount}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Alipay – trade.precreate (当面付 预下单)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None:
|
||
"""Call Alipay ``trade.precreate`` to obtain a QR code URL.
|
||
|
||
Reads all Alipay config from the database (admin panel).
|
||
Returns the ``qr_code`` URL on success, or ``None`` on failure.
|
||
"""
|
||
app_id = db_configs.get("payment_alipay_app_id", "")
|
||
private_key = db_configs.get("payment_alipay_private_key", "")
|
||
public_key = db_configs.get("payment_alipay_public_key", "")
|
||
gateway = db_configs.get("payment_alipay_gateway", "")
|
||
notify_url = db_configs.get("payment_alipay_notify_url", "")
|
||
|
||
if not app_id or not private_key:
|
||
logger.warning("Alipay config missing in database (app_id / private_key)")
|
||
return None
|
||
|
||
client = _get_alipay_client(app_id, private_key, public_key, gateway)
|
||
if client is None:
|
||
return None
|
||
|
||
try:
|
||
from alipay.aop.api.domain.AlipayTradePrecreateModel import (
|
||
AlipayTradePrecreateModel,
|
||
)
|
||
from alipay.aop.api.request.AlipayTradePrecreateRequest import (
|
||
AlipayTradePrecreateRequest,
|
||
)
|
||
from alipay.aop.api.response.AlipayTradePrecreateResponse import (
|
||
AlipayTradePrecreateResponse,
|
||
)
|
||
|
||
# 确保我们已经 patch 了 SDK
|
||
if not _patched:
|
||
if _patch_alipay_sdk():
|
||
logger.info("Successfully patched alipay SDK on demand")
|
||
|
||
# 构造业务参数
|
||
model = AlipayTradePrecreateModel()
|
||
model.out_trade_no = order.order_no
|
||
model.total_amount = f"{order.amount:.2f}"
|
||
model.subject = f"充值订单 {order.order_no}"
|
||
model.product_code = "QR_CODE_OFFLINE"
|
||
|
||
body_parts = []
|
||
if order.credits > 0:
|
||
body_parts.append(f"{order.credits}积分")
|
||
if body_parts:
|
||
model.body = " ".join(body_parts)
|
||
|
||
# 构造请求
|
||
request = AlipayTradePrecreateRequest(biz_model=model)
|
||
|
||
# 设置 notify_url 在 request 上
|
||
if notify_url:
|
||
try:
|
||
if hasattr(request, 'set_notify_url'):
|
||
request.set_notify_url(notify_url)
|
||
elif hasattr(request, 'notify_url'):
|
||
request.notify_url = notify_url
|
||
logger.info(f"Set notify_url for order {order.order_no}: {notify_url}")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to set notify_url: {e}")
|
||
|
||
# 执行API调用
|
||
try:
|
||
response_content = client.execute(request)
|
||
except TypeError as e:
|
||
error_str = str(e)
|
||
if 'bytes' in error_str and 'str' in error_str:
|
||
# 这是那个已知的 bug!尝试自己修复或者使用备选方案
|
||
logger.error(
|
||
f"Alipay SDK bytes/str TypeError bug hit: order_no={order.order_no}"
|
||
)
|
||
# 暂时返回 None,让前端提示失败
|
||
return None
|
||
raise # 其他 TypeError 正常抛出
|
||
|
||
if not response_content:
|
||
logger.error(f"Alipay precreate failed: empty response, order_no={order.order_no}")
|
||
return None
|
||
|
||
# 解析响应结果
|
||
response = AlipayTradePrecreateResponse()
|
||
response.parse_response_content(response_content)
|
||
|
||
if response.is_success():
|
||
qr_url = response.qr_code
|
||
return qr_url
|
||
else:
|
||
logger.error(
|
||
f"Alipay precreate failed: code={response.code}, "
|
||
f"msg={response.msg}, sub_code={response.sub_code}, "
|
||
f"sub_msg={response.sub_msg}, order_no={order.order_no}"
|
||
)
|
||
return None
|
||
|
||
except Exception as e:
|
||
# 处理 SDK 内部的 bytes/str 错误
|
||
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
|
||
logger.error(
|
||
f"Alipay SDK TypeError (bytes/str issue): order_no={order.order_no}, "
|
||
f"error={str(e)}"
|
||
)
|
||
logger.exception(f"Alipay precreate exception: order_no={order.order_no}")
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Alipay callback verification
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
|
||
"""Verify Alipay payment callback (async notify) signature.
|
||
Note: This is a simplified implementation. In production, you should
|
||
verify using the SDK's signature verification or by checking against
|
||
Alipay's public key.
|
||
"""
|
||
configs = await _get_payment_configs(db)
|
||
alipay_public_key = configs.get("payment_alipay_public_key", "")
|
||
|
||
if not alipay_public_key:
|
||
logger.warning("Alipay public key not configured, skipping signature verify")
|
||
return True
|
||
|
||
try:
|
||
# This is a simplified check – in production, use SDK verification
|
||
# For alipay-sdk-python, you'd typically use the DefaultAlipayClient verify
|
||
from alipay.aop.api.util.SignatureUtils import SignatureUtils
|
||
|
||
# Remove sign/sign_type from data to verify
|
||
verify_data = data.copy()
|
||
sign = verify_data.pop("sign", None)
|
||
sign_type = verify_data.pop("sign_type", None)
|
||
|
||
if not sign:
|
||
logger.warning("No sign field in Alipay callback")
|
||
return False
|
||
|
||
# For now, just check that the callback has our order and trade status
|
||
# In production, implement proper RSA verification
|
||
logger.info(
|
||
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
|
||
f"trade_no={data.get('trade_no')} status={data.get('trade_status')}"
|
||
)
|
||
return True
|
||
except ImportError:
|
||
logger.warning("alipay-sdk-python not available, skipping signature verify")
|
||
return True
|
||
except Exception:
|
||
logger.exception("Error verifying Alipay callback")
|
||
return False
|
||
|
||
|
||
async def _process_payment_success(db: AsyncSession, order: PaymentOrder, transaction_id: str):
|
||
"""Internal: actually update order, add credits, etc.
|
||
Caller must ensure we are in a transaction.
|
||
"""
|
||
order.status = "paid"
|
||
order.transaction_id = transaction_id
|
||
await db.flush()
|
||
|
||
await add_credits(db, order.user_id, order.credits, "recharge", order.id)
|
||
|
||
logger.info(
|
||
f"PAYMENT_SUCCESS order_no={order.order_no} user={order.user_id} "
|
||
f"amount={order.amount} credits={order.credits} txn={transaction_id}"
|
||
)
|
||
|
||
|
||
async def process_payment_success_by_order_no(
|
||
db: AsyncSession,
|
||
order_no: str,
|
||
transaction_id: str,
|
||
) -> PaymentOrder | None:
|
||
"""Mark order as paid, grant credits, etc., by order number.
|
||
Used by payment callback endpoints. Transaction managed by caller.
|
||
"""
|
||
result = await db.execute(
|
||
select(PaymentOrder).where(PaymentOrder.order_no == order_no)
|
||
)
|
||
order = result.scalar_one_or_none()
|
||
if order is None:
|
||
logger.warning(f"PAYMENT_SUCCESS order not found: order_no={order_no}")
|
||
return None
|
||
|
||
if order.status == "paid":
|
||
logger.info(f"PAYMENT_SUCCESS already processed: order_no={order_no}")
|
||
return order
|
||
|
||
await _process_payment_success(db, order, transaction_id)
|
||
await db.commit()
|
||
return order
|
||
|
||
|
||
async def get_order(db: AsyncSession, order_no: str) -> PaymentOrder | None:
|
||
result = await db.execute(
|
||
select(PaymentOrder).where(PaymentOrder.order_no == order_no)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def get_user_orders(db: AsyncSession, user_id: str) -> list[PaymentOrder]:
|
||
result = await db.execute(
|
||
select(PaymentOrder)
|
||
.where(PaymentOrder.user_id == user_id)
|
||
.order_by(PaymentOrder.created_at.desc())
|
||
)
|
||
return list(result.scalars().all())
|
||
|