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 # --------------------------------------------------------------------------- # 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 的一个已知 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 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.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, amount: float, credits: float, payment_method: str, ) -> 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 = configs.get("payment_mock", "false").lower() == "true" order = PaymentOrder( id=generate_id(), user_id=user_id, order_no=generate_order_no(), amount=amount, credits=credits, payment_method=payment_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={amount} credits={credits} method={payment_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 payment_method == "alipay": qr_url = _create_alipay_order(order, configs) if qr_url: order.qr_url = qr_url await db.flush() elif payment_method == "wechat": _create_wechat_order(order, configs) # Wechat would get a qr_url too, but stubbed for now await db.commit() return order # --------------------------------------------------------------------------- # 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())