Files
video-gen/video-gen-api/app/services/payment.py
T
2026-06-11 10:04:18 +08:00

565 lines
20 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.
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
__all__ = [
"create_recharge_order",
"verify_alipay_callback",
"verify_wechat_callback",
"process_payment_success_by_order_no",
"get_order",
"get_user_orders",
"expire_all_pending_orders",
"_get_payment_configs",
"_is_mock_mode",
"_check_and_expire_order",
]
# ---------------------------------------------------------------------------
# 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 的一个已知 bugWebUtils.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()}
def _is_mock_mode(configs: dict[str, str]) -> bool:
return configs.get("payment_mock", "false").lower() == "true"
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,
credits: float,
price: float,
label: str,
bonus_credits: float = 0,
method: str = "alipay",
) -> 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 = _is_mock_mode(configs)
total_credits = credits + bonus_credits
order = PaymentOrder(
id=generate_id(),
user_id=user_id,
order_no=generate_order_no(),
amount=price,
credits=total_credits,
payment_method=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={price} credits={total_credits} method={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 method == "alipay":
qr_url = _create_alipay_order(order, configs)
if qr_url:
order.qr_url = qr_url
await db.flush()
elif method == "wechat":
_create_wechat_order(order, configs)
# Wechat would get a qr_url too, but stubbed for now
await db.commit()
return order
async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
"""Verify Wechat Pay callback signature.
Note: This is a stub implementation.
"""
configs = await _get_payment_configs(db)
wechat_api_key = configs.get("payment_wechat_api_key", "")
if not wechat_api_key:
logger.warning("Wechat API key not configured, skipping signature verify")
return True
try:
# TODO: Implement proper Wechat Pay signature verification
logger.info(
f"WECHAT_CALLBACK order_no={data.get('out_trade_no')} "
f"transaction_id={data.get('transaction_id')}"
)
return True
except Exception:
logger.exception("Error verifying Wechat callback")
return False
# ---------------------------------------------------------------------------
# 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())