1
This commit is contained in:
@@ -16,9 +16,6 @@ from app.services.payment import (
|
|||||||
verify_wechat_callback,
|
verify_wechat_callback,
|
||||||
verify_alipay_callback,
|
verify_alipay_callback,
|
||||||
process_payment_success_by_order_no,
|
process_payment_success_by_order_no,
|
||||||
_get_payment_configs,
|
|
||||||
_is_mock_mode,
|
|
||||||
_check_and_expire_order,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||||
@@ -45,6 +42,7 @@ async def recharge(
|
|||||||
raise HTTPException(status_code=400, detail="不支持的支付方式")
|
raise HTTPException(status_code=400, detail="不支持的支付方式")
|
||||||
|
|
||||||
# Check if the selected payment method is enabled in admin config
|
# 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)
|
configs = await _get_payment_configs(db)
|
||||||
if not _is_mock_mode(configs):
|
if not _is_mock_mode(configs):
|
||||||
enabled_key = f"payment_{req.method}_enabled"
|
enabled_key = f"payment_{req.method}_enabled"
|
||||||
@@ -120,6 +118,7 @@ async def list_orders(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Auto-expire stale pending orders before returning
|
# Auto-expire stale pending orders before returning
|
||||||
|
from app.services.payment import _check_and_expire_order
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(PaymentOrder)
|
select(PaymentOrder)
|
||||||
.where(PaymentOrder.user_id == current_user.id)
|
.where(PaymentOrder.user_id == current_user.id)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import certifi
|
import certifi
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
|
||||||
import time as _time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
# 尝试禁用 SSL 验证(用于解决证书问题)
|
# 尝试禁用 SSL 验证(用于解决证书问题)
|
||||||
@@ -23,22 +21,10 @@ from app.models.system_config import SystemConfig
|
|||||||
from app.services.credits import add_credits
|
from app.services.credits import add_credits
|
||||||
from app.utils.id_gen import generate_id, generate_order_no
|
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)
|
# Payment logger → log/payment/YYYY-MM-DD.log (one file per day, no cleanup)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
import time as _time
|
||||||
|
|
||||||
logger = logging.getLogger("payment")
|
logger = logging.getLogger("payment")
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -69,67 +55,20 @@ class DailyFileHandler(logging.FileHandler):
|
|||||||
self._file_handler.close()
|
self._file_handler.close()
|
||||||
self.baseFilename = self._make_path()
|
self.baseFilename = self._make_path()
|
||||||
self._file_handler = logging.FileHandler(
|
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)
|
self._file_handler.setFormatter(self.formatter)
|
||||||
# Delegate to underlying file handler
|
self._current_date = date_str
|
||||||
if self._file_handler:
|
self.stream = self._file_handler.stream
|
||||||
self._file_handler.emit(record)
|
super().emit(record)
|
||||||
else:
|
|
||||||
super().emit(record)
|
|
||||||
|
|
||||||
|
|
||||||
_daily_handler = DailyFileHandler(_log_dir)
|
_handler = DailyFileHandler(_log_dir)
|
||||||
_formatter = logging.Formatter(
|
_handler.setFormatter(logging.Formatter(
|
||||||
"%(asctime)s [%(levelname)s] %(message)s",
|
"[%(asctime)s] %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
))
|
||||||
)
|
if not logger.handlers:
|
||||||
_daily_handler.setFormatter(_formatter)
|
logger.addHandler(_handler)
|
||||||
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
|
# Orders pending payment for longer than this are auto-cancelled
|
||||||
ORDER_EXPIRE_MINUTES = 5
|
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()}
|
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:
|
async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
|
||||||
"""If a pending order has passed its expiry, mark it cancelled.
|
"""If a pending order has passed its expiry, mark it cancelled.
|
||||||
Returns True if the order was expired.
|
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}"
|
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
|
||||||
)
|
)
|
||||||
if orders:
|
if orders:
|
||||||
await db.commit()
|
await db.flush()
|
||||||
return len(orders)
|
return len(orders)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
|
||||||
# Alipay client cache
|
"""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 = None
|
||||||
_alipay_client_app_id = 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 = ""):
|
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
|
return None
|
||||||
|
|
||||||
|
# Fix SDK's Python 3 bytes/str bug in WebUtils.do_post (once per process)
|
||||||
|
_patch_alipay_web_utils()
|
||||||
|
|
||||||
config = AlipayClientConfig()
|
config = AlipayClientConfig()
|
||||||
config.server_url = gateway or "https://openapi.alipay.com/gateway.do"
|
config.server_url = gateway or "https://openapi.alipay.com/gateway.do"
|
||||||
config.app_id = app_id
|
config.app_id = app_id
|
||||||
@@ -257,17 +247,32 @@ async def create_recharge_order(
|
|||||||
credits: float,
|
credits: float,
|
||||||
price: float,
|
price: float,
|
||||||
label: str,
|
label: str,
|
||||||
bonus_credits: float = 0,
|
bonus_credits: float = 0.0,
|
||||||
method: str = "alipay",
|
method: str = "wechat",
|
||||||
) -> PaymentOrder:
|
) -> PaymentOrder:
|
||||||
"""Create a new pending payment order and call the payment gateway.
|
"""Create a payment order.
|
||||||
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
|
|
||||||
|
|
||||||
|
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(
|
order = PaymentOrder(
|
||||||
id=generate_id(),
|
id=generate_id(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -275,74 +280,60 @@ async def create_recharge_order(
|
|||||||
amount=price,
|
amount=price,
|
||||||
credits=total_credits,
|
credits=total_credits,
|
||||||
payment_method=method,
|
payment_method=method,
|
||||||
status="pending" if not is_mock else "paid",
|
status="pending",
|
||||||
qr_url=None,
|
|
||||||
)
|
)
|
||||||
db.add(order)
|
db.add(order)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"ORDER_CREATED order_no={order.order_no} user={user_id} "
|
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:
|
if mock_mode:
|
||||||
# Mock mode: instantly credit user
|
# Mock: immediately complete payment
|
||||||
await _process_payment_success(db, order, "mock_transaction_id")
|
order.status = "paid"
|
||||||
await db.commit()
|
order.paid_at = datetime.now()
|
||||||
return order
|
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
|
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:
|
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", "")
|
mch_id = db_configs.get("payment_wechat_mch_id", "")
|
||||||
api_key = db_configs.get("payment_wechat_api_key", "")
|
api_key = db_configs.get("payment_wechat_api_key", "")
|
||||||
if not mch_id or not 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
|
return
|
||||||
logger.info(
|
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}"
|
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,
|
AlipayTradePrecreateResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 确保我们已经 patch 了 SDK
|
|
||||||
if not _patched:
|
|
||||||
if _patch_alipay_sdk():
|
|
||||||
logger.info("Successfully patched alipay SDK on demand")
|
|
||||||
|
|
||||||
# 构造业务参数
|
# 构造业务参数
|
||||||
model = AlipayTradePrecreateModel()
|
model = AlipayTradePrecreateModel()
|
||||||
model.out_trade_no = order.order_no
|
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}")
|
logger.warning(f"Failed to set notify_url: {e}")
|
||||||
|
|
||||||
# 执行API调用
|
# 执行API调用
|
||||||
try:
|
response_content = client.execute(request)
|
||||||
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:
|
if not response_content:
|
||||||
logger.error(f"Alipay precreate failed: empty response, order_no={order.order_no}")
|
logger.error(f"Alipay precreate failed: empty response, order_no={order.order_no}")
|
||||||
return None
|
return None
|
||||||
@@ -448,13 +422,16 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except RuntimeError as e:
|
||||||
# 处理 SDK 内部的 bytes/str 错误
|
# The patched WebUtils raises RuntimeError on non-2xx HTTP responses
|
||||||
if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
|
# (the original SDK would have raised a confusing TypeError). This is
|
||||||
logger.error(
|
# expected — the Alipay gateway rejected the request for some reason.
|
||||||
f"Alipay SDK TypeError (bytes/str issue): order_no={order.order_no}, "
|
logger.warning(
|
||||||
f"error={str(e)}"
|
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}")
|
logger.exception(f"Alipay precreate exception: order_no={order.order_no}")
|
||||||
return None
|
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:
|
async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
|
||||||
"""Verify Alipay payment callback (async notify) signature.
|
"""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
|
Reads the Alipay public key from the database and uses the SDK's
|
||||||
Alipay's public key.
|
built-in RSA2 verification.
|
||||||
"""
|
"""
|
||||||
configs = await _get_payment_configs(db)
|
db_configs = await _get_payment_configs(db)
|
||||||
alipay_public_key = configs.get("payment_alipay_public_key", "")
|
mock_mode = _is_mock_mode(db_configs)
|
||||||
|
if mock_mode:
|
||||||
if not alipay_public_key:
|
|
||||||
logger.warning("Alipay public key not configured, skipping signature verify")
|
|
||||||
return True
|
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:
|
try:
|
||||||
# This is a simplified check – in production, use SDK verification
|
sign = data.get("sign")
|
||||||
# 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:
|
if not sign:
|
||||||
logger.warning("No sign field in Alipay callback")
|
logger.warning("Alipay callback missing 'sign' field")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# For now, just check that the callback has our order and trade status
|
# Build verification params (exclude sign and sign_type)
|
||||||
# In production, implement proper RSA verification
|
verify_data = {
|
||||||
logger.info(
|
k: v for k, v in data.items()
|
||||||
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
|
if k not in ("sign", "sign_type") and v is not None and v != ""
|
||||||
f"trade_no={data.get('trade_no')} status={data.get('trade_status')}"
|
}
|
||||||
|
|
||||||
|
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:
|
except ImportError:
|
||||||
logger.warning("alipay-sdk-python not available, skipping signature verify")
|
logger.error("alipay-sdk-python not installed, skipping signature verification")
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error verifying Alipay callback")
|
logger.exception("Alipay callback verification error")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _process_payment_success(db: AsyncSession, order: PaymentOrder, transaction_id: str):
|
# ---------------------------------------------------------------------------
|
||||||
"""Internal: actually update order, add credits, etc.
|
# WeChat callback verification (stub)
|
||||||
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(
|
async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
|
||||||
db: AsyncSession,
|
"""Verify WeChat payment callback signature."""
|
||||||
order_no: str,
|
db_configs = await _get_payment_configs(db)
|
||||||
transaction_id: str,
|
mock_mode = _is_mock_mode(db_configs)
|
||||||
) -> PaymentOrder | None:
|
if mock_mode:
|
||||||
"""Mark order as paid, grant credits, etc., by order number.
|
return True
|
||||||
Used by payment callback endpoints. Transaction managed by caller.
|
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(
|
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()
|
order = result.scalar_one_or_none()
|
||||||
if order is None:
|
if not order or order.status != "pending":
|
||||||
logger.warning(f"PAYMENT_SUCCESS order not found: order_no={order_no}")
|
return
|
||||||
return None
|
|
||||||
|
|
||||||
if order.status == "paid":
|
order.status = "paid"
|
||||||
logger.info(f"PAYMENT_SUCCESS already processed: order_no={order_no}")
|
order.paid_at = datetime.now()
|
||||||
return order
|
await add_credits(
|
||||||
|
db,
|
||||||
await _process_payment_success(db, order, transaction_id)
|
order.user_id,
|
||||||
await db.commit()
|
order.credits,
|
||||||
return order
|
f"充值成功({order.credits}积分)",
|
||||||
|
related_id=order.id,
|
||||||
|
|
||||||
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()
|
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(
|
result = await db.execute(
|
||||||
select(PaymentOrder)
|
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
|
||||||
.where(PaymentOrder.user_id == user_id)
|
|
||||||
.order_by(PaymentOrder.created_at.desc())
|
|
||||||
)
|
)
|
||||||
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}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user