452 lines
15 KiB
Python
452 lines
15 KiB
Python
import logging
|
||
import os
|
||
from datetime import datetime, timedelta
|
||
from logging.handlers import TimedRotatingFileHandler
|
||
|
||
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 → logs/payment/YYYY-MM-DD.log (one file per day, keep 30 days)
|
||
# ---------------------------------------------------------------------------
|
||
logger = logging.getLogger("payment")
|
||
logger.setLevel(logging.INFO)
|
||
|
||
_log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "payment")
|
||
os.makedirs(_log_dir, exist_ok=True)
|
||
|
||
_file_handler = TimedRotatingFileHandler(
|
||
os.path.join(_log_dir, "payment.log"),
|
||
when="midnight",
|
||
interval=1,
|
||
backupCount=0,
|
||
encoding="utf-8",
|
||
utc=False,
|
||
)
|
||
_file_handler.suffix = "%Y-%m-%d"
|
||
_file_handler.setFormatter(logging.Formatter(
|
||
"[%(asctime)s] %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||
))
|
||
if not logger.handlers:
|
||
logger.addHandler(_file_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"
|
||
|
||
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}"
|
||
|
||
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}"
|
||
)
|