Files
video-gen/video-gen-api/app/services/payment.py
T
2026-06-10 16:29:13 +08:00

480 lines
16 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
from datetime import datetime, timedelta
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)
# ---------------------------------------------------------------------------
import time as _time
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="a", encoding=self.encoding
)
self._file_handler.setFormatter(self.formatter)
self._current_date = date_str
self.stream = self._file_handler.stream
super().emit(record)
_handler = DailyFileHandler(_log_dir)
_handler.setFormatter(logging.Formatter(
"[%(asctime)s] %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
))
if not logger.handlers:
logger.addHandler(_handler)
# 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.flush()
return len(orders)
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
"""Check if payment mock mode is enabled (from DB or env)."""
db_val = db_configs.get("payment_mock", "")
if db_val:
return db_val.lower() in ("true", "1", "yes")
return settings.PAYMENT_MOCK
# ---------------------------------------------------------------------------
# Alipay client (lazy singleton, recreated when config changes)
# ---------------------------------------------------------------------------
_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"
config.cert_path = certifi.where()
os.environ["SSL_CERT_FILE"] = certifi.where()
os.environ["REQUESTS_CA_BUNDLE"] = certifi.where()
try:
_alipay_client = DefaultAlipayClient(config)
_alipay_client_app_id = app_id
except Exception:
logger.exception("Failed to initialize Alipay client")
_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.0,
method: str = "wechat",
) -> PaymentOrder:
"""Create a payment order.
Reads payment config from the database (admin panel).
Returns the order; for Alipay the ``qr_url`` attribute will be populated
with the scan-to-pay URL.
"""
# Read config from database first
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
# In real mode, validate that the payment method is enabled and configured
if not mock_mode:
enabled_key = f"payment_{method}_enabled"
if db_configs.get(enabled_key, "").lower() != "true":
raise ValueError("该支付方式未启用,请联系管理员")
if method == "alipay":
if not db_configs.get("payment_alipay_app_id") or not db_configs.get("payment_alipay_private_key"):
raise ValueError("支付宝支付未完成配置,请联系管理员")
elif method == "wechat":
if not db_configs.get("payment_wechat_mch_id") or not db_configs.get("payment_wechat_api_key"):
raise ValueError("微信支付未完成配置,请联系管理员")
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",
)
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={mock_mode}"
)
if mock_mode:
# Mock: immediately complete payment
order.status = "paid"
order.paid_at = datetime.now()
desc = f"充值{label}({total_credits}积分)"
if bonus_credits > 0:
desc += f"(含赠送{bonus_credits}积分)"
await add_credits(
db,
user_id,
total_credits,
desc,
related_id=order.id,
)
await db.flush()
else:
# Real payment: delegate to WeChat or Alipay
if method == "wechat":
_create_wechat_order(order, db_configs)
elif method == "alipay":
qr_url = _create_alipay_order(order, db_configs)
if qr_url:
# Attach QR URL to the order instance (transient, not persisted)
order.qr_url = qr_url # type: ignore[attr-defined]
else:
# Precreate failed — do not leave a pending order that can never be paid
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
return order
# ---------------------------------------------------------------------------
# WeChat (stub)
# ---------------------------------------------------------------------------
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", "")
notify_url = db_configs.get("payment_alipay_notify_url", "")
gateway = db_configs.get("payment_alipay_gateway", "")
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,
)
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()
request.biz_model = model
if notify_url:
request.notify_url = notify_url
response = client.execute(request)
if response.code == "10000":
qr_url = response.qr_code
logger.info(
f"Alipay precreate success: order_no={order.order_no}, "
f"qr_url={qr_url}"
)
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:
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.
Reads the Alipay public key from the database and uses the SDK's
built-in RSA2 verification.
"""
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
if mock_mode:
return True
public_key = db_configs.get("payment_alipay_public_key", "")
if not public_key:
logger.warning("ALIPAY_PUBLIC_KEY not found in database, cannot verify callback")
return False
try:
sign = data.get("sign")
if not sign:
logger.warning("Alipay callback missing 'sign' field")
return False
# Build verification params (exclude sign and sign_type)
verify_data = {
k: v for k, v in data.items()
if k not in ("sign", "sign_type") and v is not None and v != ""
}
from alipay.aop.api.util.Signature import verify_with_rsa
sign_content = "&".join(
f"{k}={v}" for k, v in sorted(verify_data.items())
)
is_valid = verify_with_rsa(
public_key.encode("utf-8"),
sign_content.encode("utf-8"),
sign,
)
if not is_valid:
logger.warning("Alipay callback signature verification FAILED")
return is_valid
except ImportError:
logger.error("alipay-sdk-python not installed, skipping signature verification")
return True
except Exception:
logger.exception("Alipay callback verification error")
return False
# ---------------------------------------------------------------------------
# WeChat callback verification (stub)
# ---------------------------------------------------------------------------
async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
"""Verify WeChat payment callback signature."""
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
if mock_mode:
return True
logger.info("WeChat callback verification (real mode not implemented)")
return True
# ---------------------------------------------------------------------------
# Process successful payment
# ---------------------------------------------------------------------------
async def process_payment_success(db: AsyncSession, order_id: str):
"""Process successful payment: update order and add credits."""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1)
)
order = result.scalar_one_or_none()
if not order or order.status != "pending":
return
order.status = "paid"
order.paid_at = datetime.now()
await add_credits(
db,
order.user_id,
order.credits,
f"充值成功({order.credits}积分)",
related_id=order.id,
)
await db.flush()
async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, trade_no: str = ""):
"""Process successful payment by order_no (used by Alipay/WeChat callbacks).
Args:
db: async database session
order_no: the merchant order number (out_trade_no)
trade_no: the Alipay trade number (trade_no), optional
"""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
)
order = result.scalar_one_or_none()
if not order or order.status != "pending":
logger.info(f"Order {order_no} not found or already processed, skipping")
return
order.status = "paid"
order.paid_at = datetime.now()
if trade_no:
order.trade_no = trade_no
await add_credits(
db,
order.user_id,
order.credits,
f"充值成功({order.credits}积分)",
related_id=order.id,
)
await db.flush()
logger.info(
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
)