This commit is contained in:
2026-06-11 10:49:10 +08:00
parent 64f8be96b5
commit 0ed33b8b8d
2 changed files with 241 additions and 236 deletions
+2 -3
View File
@@ -16,9 +16,6 @@ from app.services.payment import (
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
_get_payment_configs,
_is_mock_mode,
_check_and_expire_order,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -45,6 +42,7 @@ async def recharge(
raise HTTPException(status_code=400, detail="不支持的支付方式")
# Check if the selected payment method is enabled in admin config
from app.services.payment import _get_payment_configs, _is_mock_mode
configs = await _get_payment_configs(db)
if not _is_mock_mode(configs):
enabled_key = f"payment_{req.method}_enabled"
@@ -120,6 +118,7 @@ async def list_orders(
db: AsyncSession = Depends(get_db),
):
# Auto-expire stale pending orders before returning
from app.services.payment import _check_and_expire_order
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id)
+239 -233
View File
@@ -2,8 +2,6 @@ import logging
import os
import certifi
import ssl
import sys
import time as _time
from datetime import datetime, timedelta
# 尝试禁用 SSL 验证(用于解决证书问题)
@@ -23,22 +21,10 @@ 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)
# ---------------------------------------------------------------------------
import time as _time
logger = logging.getLogger("payment")
logger.setLevel(logging.INFO)
@@ -69,67 +55,20 @@ class DailyFileHandler(logging.FileHandler):
self._file_handler.close()
self.baseFilename = self._make_path()
self._file_handler = logging.FileHandler(
self.baseFilename, mode=self.mode, encoding=self.encoding
self.baseFilename, mode="a", 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)
self._current_date = date_str
self.stream = self._file_handler.stream
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()
_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
@@ -148,10 +87,6 @@ async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
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.
@@ -188,16 +123,68 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
)
if orders:
await db.commit()
await db.flush()
return len(orders)
# ---------------------------------------------------------------------------
# Alipay client cache
# ---------------------------------------------------------------------------
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
_alipay_web_utils_patched = False
def _patch_alipay_web_utils():
"""Monkey-patch alipay SDK WebUtils.do_post to fix Python 3 bytes/str TypeError.
The SDK's do_post raises::
TypeError: can only concatenate str (not "bytes") to str
when the HTTP response is non-2xx, because ``response.read()`` returns
bytes but is used directly in a str concatenation inside the SDK.
"""
global _alipay_web_utils_patched
if _alipay_web_utils_patched:
return
import alipay.aop.api.util.WebUtils as _web_utils
_original_do_post = _web_utils.do_post
def _patched_do_post(url, query_string, headers, params, charset, timeout):
try:
return _original_do_post(url, query_string, headers, params, charset, timeout)
except TypeError as e:
err_str = str(e)
if "bytes" not in err_str and "str" not in err_str:
raise
# SDK bug: response.read() returned bytes but was used in str concat.
# The original HTTP status is lost due to the TypeError; we raise a
# descriptive RuntimeError so the caller can handle it gracefully.
try:
from alipay.aop.api.util.WebUtils import THREAD_LOCAL
uuid = THREAD_LOCAL.uuid
except Exception:
uuid = "???"
raise RuntimeError(
f"[{uuid}] Alipay HTTP request failed (non-2xx response). "
f"The SDK raised a bytes/str TypeError. "
f"URL: {url}"
) from e
_web_utils.do_post = _patched_do_post
_alipay_web_utils_patched = True
def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway: str = ""):
@@ -217,6 +204,9 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway:
)
return None
# Fix SDK's Python 3 bytes/str bug in WebUtils.do_post (once per process)
_patch_alipay_web_utils()
config = AlipayClientConfig()
config.server_url = gateway or "https://openapi.alipay.com/gateway.do"
config.app_id = app_id
@@ -257,17 +247,32 @@ async def create_recharge_order(
credits: float,
price: float,
label: str,
bonus_credits: float = 0,
method: str = "alipay",
bonus_credits: float = 0.0,
method: str = "wechat",
) -> 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
"""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,
@@ -275,74 +280,60 @@ async def create_recharge_order(
amount=price,
credits=total_credits,
payment_method=method,
status="pending" if not is_mock else "paid",
qr_url=None,
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={is_mock}"
f"amount={price} credits={total_credits} method={method} mock={mock_mode}"
)
if is_mock:
# Mock mode: instantly credit user
await _process_payment_success(db, order, "mock_transaction_id")
await db.commit()
return order
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("支付宝预下单失败,请检查配置或稍后重试")
# 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
# WeChat (stub)
# ---------------------------------------------------------------------------
def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> None:
"""Create a Wechat Pay order. Stub for real integration."""
"""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")
logger.warning("WeChat payment config missing in database")
return
logger.info(
f"Wechat order created: mch_id={mch_id}, "
f"WeChat order created: mch_id={mch_id}, "
f"order_no={order.order_no}, amount={order.amount}"
)
@@ -383,11 +374,6 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
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
@@ -416,19 +402,7 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
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 正常抛出
response_content = client.execute(request)
if not response_content:
logger.error(f"Alipay precreate failed: empty response, order_no={order.order_no}")
return None
@@ -448,13 +422,16 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
)
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)}"
)
except RuntimeError as e:
# The patched WebUtils raises RuntimeError on non-2xx HTTP responses
# (the original SDK would have raised a confusing TypeError). This is
# expected — the Alipay gateway rejected the request for some reason.
logger.warning(
f"Alipay precreate HTTP error: order_no={order.order_no}, "
f"detail={str(e)}"
)
return None
except Exception:
logger.exception(f"Alipay precreate exception: order_no={order.order_no}")
return None
@@ -466,99 +443,128 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
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.
Reads the Alipay public key from the database and uses the SDK's
built-in RSA2 verification.
"""
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")
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:
# 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)
sign = data.get("sign")
if not sign:
logger.warning("No sign field in Alipay callback")
logger.warning("Alipay callback missing 'sign' field")
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')}"
# 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())
)
return True
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.warning("alipay-sdk-python not available, skipping signature verify")
logger.error("alipay-sdk-python not installed, skipping signature verification")
return True
except Exception:
logger.exception("Error verifying Alipay callback")
logger.exception("Alipay callback verification error")
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}"
)
# ---------------------------------------------------------------------------
# WeChat callback verification (stub)
# ---------------------------------------------------------------------------
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.
"""
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.order_no == order_no)
select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1)
)
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 not order or order.status != "pending":
return
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)
order.status = "paid"
order.paid_at = datetime.now()
await add_credits(
db,
order.user_id,
order.credits,
f"充值成功({order.credits}积分)",
related_id=order.id,
)
return result.scalar_one_or_none()
await db.flush()
async def get_user_orders(db: AsyncSession, user_id: str) -> list[PaymentOrder]:
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.user_id == user_id)
.order_by(PaymentOrder.created_at.desc())
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
)
return list(result.scalars().all())
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}"
)