import logging import os import json from datetime import datetime, timedelta # 尝试设置 SSL 证书路径 try: import certifi os.environ["SSL_CERT_FILE"] = certifi.where() os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() except ImportError: pass 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, deduct_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) # Order expire time in seconds (configurable via payment_order_timeout setting, default 180 seconds) DEFAULT_ORDER_EXPIRE_SECONDS = 180 def _get_order_expire_seconds(db_configs: dict[str, str]) -> int: """Get order expire time in seconds from config, with fallback to 180.""" try: val = db_configs.get("payment_order_timeout", str(DEFAULT_ORDER_EXPIRE_SECONDS)) return int(val) if val.strip() else DEFAULT_ORDER_EXPIRE_SECONDS except ValueError: return DEFAULT_ORDER_EXPIRE_SECONDS # --------------------------------------------------------------------------- # Monkey-patch alipay-sdk-python WebUtils.do_post to fix bytes concatenation bug # The SDK's error handling does: '...' + response.read() # but response.read() returns bytes, causing TypeError on Python 3 # --------------------------------------------------------------------------- def _patch_alipay_webutils(): try: from alipay.aop.api.util import WebUtils _original_do_post = WebUtils.do_post def _patched_do_post(url, query_string, headers, params, charset, timeout=30): try: return _original_do_post(url, query_string, headers, params, charset, timeout) except TypeError as e: if "can only concatenate str (not 'bytes') to str" in str(e): # Decode bytes response to string and retry import http.client as _http from urllib.parse import urlparse as _urlparse parsed = _urlparse(url) conn = _http.HTTPSConnection(parsed.hostname) conn.request("POST", parsed.path + "?" + query_string, params, headers) resp = conn.getresponse() body = resp.read().decode("utf-8", errors="replace") raise RuntimeError(f"Alipay API error (status {resp.status}): {body}") from e raise WebUtils.do_post = _patched_do_post except ImportError: pass _patch_alipay_webutils() # --------------------------------------------------------------------------- # 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 db_configs = await _get_payment_configs(db) expire_seconds = _get_order_expire_seconds(db_configs) expiry = order.created_at + timedelta(seconds=expire_seconds) 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()}" ) # Also call close API if it was an Alipay or WeChat order if order.payment_method == "alipay": try: await _close_alipay_order(db, order, db_configs) except Exception as e: logger.exception(f"Failed to close Alipay order {order.order_no}: {e}") elif order.payment_method == "wechat": try: await _close_wechat_order(db, order, db_configs) except Exception as e: logger.exception(f"Failed to close WeChat order {order.order_no}: {e}") 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. """ db_configs = await _get_payment_configs(db) expire_seconds = _get_order_expire_seconds(db_configs) threshold = datetime.now() - timedelta(seconds=expire_seconds) result = await db.execute( select(PaymentOrder).where( PaymentOrder.status == "pending", PaymentOrder.created_at <= threshold, ) ) orders = result.scalars().all() expired_count = 0 for o in orders: o.status = "cancelled" expired_count += 1 logger.info( f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}" ) # Also call close API if it was an Alipay or WeChat order if o.payment_method == "alipay": try: await _close_alipay_order(db, o, db_configs) except Exception as e: logger.exception(f"Failed to close Alipay order {o.order_no}: {e}") elif o.payment_method == "wechat": try: await _close_wechat_order(db, o, db_configs) except Exception as e: logger.exception(f"Failed to close WeChat order {o.order_no}: {e}") if orders: await db.flush() return expired_count 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, logger) _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": required_configs = [ "payment_wechat_appid", "payment_wechat_mch_id", "payment_wechat_private_key", "payment_wechat_cert_serial_no", "payment_wechat_api_v3_key" ] missing_configs = [c for c in required_configs if not db_configs.get(c)] if missing_configs: raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员") 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": qr_code_content = _create_wechat_order(order, db_configs) if qr_code_content: # Attach QR code content to the order instance (transient, not persisted) order.qr_url = qr_code_content # type: ignore[attr-defined] else: # Precreate failed — do not leave a pending order that can never be paid raise ValueError("微信支付预下单失败,请检查配置或稍后重试") 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 Pay client (lazy singleton, recreated when config changes) # --------------------------------------------------------------------------- _wechat_client = None _wechat_mch_id = None def _parse_wechat_result(result) -> dict: """解析微信支付SDK返回的result为字典""" data = {} if isinstance(result, dict): data = result elif isinstance(result, str): try: data = json.loads(result) except: pass elif hasattr(result, 'get'): data = result elif hasattr(result, '__dict__'): data = result.__dict__ return data def _get_wechat_client( mch_id: str, private_key: str, cert_serial_no: str, api_v3_key: str, appid: str, notify_url: str = "", public_key: str = None, public_key_id: str = None ): """Get or create a WeChat Pay client. Recreated if config changes.""" global _wechat_client, _wechat_mch_id if _wechat_client is not None and _wechat_mch_id == mch_id: return _wechat_client try: from wechatpayv3 import WeChatPay, WeChatPayType except ImportError: logger.error( "wechatpayv3 is not installed. " "Install it with: pip install wechatpayv3" ) return None try: # 初始化微信支付客户端 wechatpay_args = { "wechatpay_type": WeChatPayType.NATIVE, "mchid": mch_id, "private_key": private_key.strip(), "cert_serial_no": cert_serial_no, "appid": appid, "apiv3_key": api_v3_key, "notify_url": notify_url, "logger": logger, } # 如果配置了 public_key 和 public_key_id,就使用它们,否则不设置 if public_key and public_key_id: wechatpay_args["public_key"] = public_key wechatpay_args["public_key_id"] = public_key_id else: logger.info("No platform public key configured, will try to download") _wechat_client = WeChatPay(**wechatpay_args) _wechat_mch_id = mch_id return _wechat_client except Exception as e: logger.exception( "Failed to initialize WeChat Pay client: %s\n" "Please double-check your merchant private key configuration.\n" "It should be from apiclient_key.pem, NOT apiclient_cert.pem!", e ) _wechat_client = None _wechat_mch_id = None return None def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None: """Create a WeChat Pay Native order. Returns QR code content (code_url). Reads all WeChat config from the database (admin panel). Returns the ``code_url`` on success, or ``None`` on failure. """ mch_id = db_configs.get("payment_wechat_mch_id", "") private_key = db_configs.get("payment_wechat_private_key", "") cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") appid = db_configs.get("payment_wechat_appid", "") notify_url = db_configs.get("payment_wechat_notify_url", "") public_key = db_configs.get("payment_wechat_public_key", "") public_key_id = db_configs.get("payment_wechat_public_key_id", "") if not all([mch_id, private_key, cert_serial_no, api_v3_key, appid]): logger.warning("WeChat payment config missing in database") return None client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, appid, notify_url, public_key, public_key_id) if client is None: return None try: # 调用微信支付 Native 下单接口 code, result = client.pay( description=f"充值订单 {order.order_no}", out_trade_no=order.order_no, amount={ "total": int(order.amount * 100), # 微信支付以分为单位 "currency": "CNY" }, notify_url=notify_url, scene_info={ "payer_client_ip": "127.0.0.1", } ) data = _parse_wechat_result(result) if code == 200 and data.get('code_url'): # 注意:微信返回的 code_url 可能需要进一步处理成二维码图片地址 logger.info(f"WeChat order created successfully: order_no={order.order_no}") return data.get('code_url') else: logger.error( f"WeChat pay failed: order_no={order.order_no}, " f"code={code}, result={result}" ) return None except Exception as e: logger.exception(f"WeChat pay exception: order_no={order.order_no}") return None async def _close_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool: """Call WeChat Pay close API to close an unpaid order. Returns True if the order was closed successfully. """ mch_id = db_configs.get("payment_wechat_mch_id", "") private_key = db_configs.get("payment_wechat_private_key", "") cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") appid = db_configs.get("payment_wechat_appid", "") public_key = db_configs.get("payment_wechat_public_key", "") public_key_id = db_configs.get("payment_wechat_public_key_id", "") notify_url = db_configs.get("payment_wechat_notify_url", "") client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, appid, notify_url, public_key, public_key_id) if client is None: return False mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info(f"Mock mode: skipping close_wechat_order for {order.order_no}") return True try: code, result = client.close(out_trade_no=order.order_no) if code == 204: logger.info(f"WeChat order closed: order_no={order.order_no}") return True else: logger.error(f"WeChat close failed: order_no={order.order_no}, code={code}, result={result}") return False except Exception as e: logger.exception(f"WeChat close exception: order_no={order.order_no}") return False async def _query_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None: """Call WeChat Pay query API to check order status. Returns the response data if successful, None otherwise. """ mch_id = db_configs.get("payment_wechat_mch_id", "") private_key = db_configs.get("payment_wechat_private_key", "") cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") appid = db_configs.get("payment_wechat_appid", "") public_key = db_configs.get("payment_wechat_public_key", "") public_key_id = db_configs.get("payment_wechat_public_key_id", "") notify_url = db_configs.get("payment_wechat_notify_url", "") client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, appid, notify_url, public_key, public_key_id) if client is None: return None mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info(f"Mock mode: skipping query_wechat_order for {order.order_no}") return {"trade_state": "SUCCESS"} try: code, result = client.query(out_trade_no=order.order_no) data = _parse_wechat_result(result) if code == 200 and data.get('trade_state'): logger.info( f"WeChat query succeeded: order_no={order.order_no}, " f"trade_state={data.get('trade_state')}" ) return data else: logger.error( f"WeChat query failed: order_no={order.order_no}, " f"code={code}, result={result}" ) return None except Exception as e: logger.exception(f"WeChat query exception: order_no={order.order_no}") return None async def _refund_wechat_order( db: AsyncSession, order: PaymentOrder, refund_amount: float, refund_reason: str, db_configs: dict[str, str] ) -> dict: """Call WeChat Pay refund API.""" mch_id = db_configs.get("payment_wechat_mch_id", "") private_key = db_configs.get("payment_wechat_private_key", "") cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "") api_v3_key = db_configs.get("payment_wechat_api_v3_key", "") appid = db_configs.get("payment_wechat_appid", "") public_key = db_configs.get("payment_wechat_public_key", "") public_key_id = db_configs.get("payment_wechat_public_key_id", "") notify_url = db_configs.get("payment_wechat_notify_url", "") client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, appid, notify_url, public_key, public_key_id) if client is None: return {"success": False, "message": "微信支付客户端初始化失败"} mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info(f"Mock mode: skipping wechat refund for {order.order_no}") return {"success": True} try: out_refund_no = f"{order.order_no}_refund_{int(datetime.now().timestamp())}" code, result = client.refund( out_trade_no=order.order_no, out_refund_no=out_refund_no, amount={ "total": int(order.amount * 100), # 订单总金额 "refund": int(refund_amount * 100), # 退款金额 "currency": "CNY" }, reason=refund_reason ) data = _parse_wechat_result(result) if code == 200 and data.get('status') == 'SUCCESS': logger.info(f"WeChat refund succeeded: order_no={order.order_no}") return {"success": True, "refund_id": data.get('refund_id')} else: logger.error( f"WeChat refund failed: order_no={order.order_no}, " f"code={code}, result={result}" ) return { "success": False, "message": f"微信退款失败: code={code}, {data.get('code', '')}" } except Exception as e: logger.exception(f"WeChat refund exception: order_no={order.order_no}") return {"success": False, "message": f"微信退款异常: {str(e)}"} # --------------------------------------------------------------------------- # 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, ) # 构造业务参数 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 except Exception as e: logger.warning(f"Failed to set notify_url: {e}") # 执行API调用 response_content = client.execute(request) 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 order close # --------------------------------------------------------------------------- async def _close_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool: """Call Alipay trade.close API to close an unpaid order. Returns True if the order was closed successfully. """ 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", "") client = _get_alipay_client(app_id, private_key, public_key, gateway) if client is None: return False mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info(f"Mock mode: skipping close_alipay_order for {order.order_no}") return True try: from alipay.aop.api.domain.AlipayTradeCloseModel import AlipayTradeCloseModel from alipay.aop.api.request.AlipayTradeCloseRequest import AlipayTradeCloseRequest from alipay.aop.api.response.AlipayTradeCloseResponse import AlipayTradeCloseResponse model = AlipayTradeCloseModel() model.out_trade_no = order.order_no request = AlipayTradeCloseRequest(biz_model=model) response_content = client.execute(request) if not response_content: logger.error(f"Alipay close failed: empty response, order_no={order.order_no}") return False response = AlipayTradeCloseResponse() response.parse_response_content(response_content) if response.is_success(): logger.info(f"Alipay order closed: order_no={order.order_no}") return True else: logger.error( f"Alipay close 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 False except Exception as e: if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)): logger.error( f"Alipay SDK TypeError (bytes/str issue) during close: order_no={order.order_no}, " f"error={str(e)}" ) logger.exception(f"Alipay close exception: order_no={order.order_no}") return False # --------------------------------------------------------------------------- # Alipay order query # --------------------------------------------------------------------------- async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None: """Call Alipay trade.query API to check order status. Returns the response data if successful, None otherwise. """ 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", "") client = _get_alipay_client(app_id, private_key, public_key, gateway) if client is None: return None mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info(f"Mock mode: skipping query_alipay_order for {order.order_no}") return {"trade_status": "TRADE_FINISHED"} try: from alipay.aop.api.domain.AlipayTradeQueryModel import AlipayTradeQueryModel from alipay.aop.api.request.AlipayTradeQueryRequest import AlipayTradeQueryRequest from alipay.aop.api.response.AlipayTradeQueryResponse import AlipayTradeQueryResponse model = AlipayTradeQueryModel() model.out_trade_no = order.order_no request = AlipayTradeQueryRequest(biz_model=model) response_content = client.execute(request) if not response_content: logger.error(f"Alipay query failed: empty response, order_no={order.order_no}") return None response = AlipayTradeQueryResponse() response.parse_response_content(response_content) if response.is_success(): logger.info(f"Alipay query succeeded: order_no={order.order_no}, trade_status={response.trade_status}") return { "trade_no": response.trade_no, "trade_status": response.trade_status, "total_amount": response.total_amount, "receipt_amount": response.receipt_amount, } else: logger.error( f"Alipay query 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: if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)): logger.error( f"Alipay SDK TypeError (bytes/str issue) during query: order_no={order.order_no}, " f"error={str(e)}" ) logger.exception(f"Alipay query exception: order_no={order.order_no}") return None async def sync_pending_orders(db: AsyncSession) -> int: """Check pending orders via Alipay/WeChat query and update status. Returns the number of orders updated. """ result = await db.execute( select(PaymentOrder).where( PaymentOrder.status == "pending", ) ) orders = result.scalars().all() updated_count = 0 db_configs = await _get_payment_configs(db) for order in orders: try: if order.payment_method == "alipay": data = await _query_alipay_order(db, order, db_configs) if data: trade_status = data.get("trade_status") if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"): # Order was paid but we missed the callback trade_no = data.get("trade_no", "") total_amount_str = data.get("total_amount", "") total_amount = float(total_amount_str) if total_amount_str else None await process_payment_success_by_order_no(db, order.order_no, trade_no, total_amount) updated_count += 1 elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"): # Order was closed on Alipay side order.status = "cancelled" await db.flush() updated_count += 1 elif order.payment_method == "wechat": data = await _query_wechat_order(db, order, db_configs) if data: trade_state = data.get("trade_state") if trade_state == "SUCCESS": # Order was paid but we missed the callback transaction_id = data.get("transaction_id", "") total_amount = float(data.get("amount", {}).get("total", 0)) / 100 await process_payment_success_by_order_no(db, order.order_no, transaction_id, total_amount) updated_count += 1 elif trade_state in ("CLOSED", "REVOKED"): # Order was closed on WeChat side order.status = "cancelled" await db.flush() updated_count += 1 except Exception as e: logger.exception(f"Failed to sync order {order.order_no}: {e}") if updated_count > 0: await db.flush() return updated_count # --------------------------------------------------------------------------- # 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 RSA2 verification. """ db_configs = await _get_payment_configs(db) mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info("Mock mode enabled, skipping Alipay callback verification") 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 sign_type = data.get("sign_type", "RSA2") # 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 != "" } # Generate sign content: sorted keys, key=value format sign_content = "&".join( f"{k}={v}" for k, v in sorted(verify_data.items()) ) # logger.info(f"Verifying Alipay callback sign_content: {sign_content[:100]}...") # logger.info(f"Sign type: {sign_type}") # 实现 RSA2 签名验证 is_valid = _verify_alipay_sign(public_key, sign_content, sign, sign_type) if not is_valid: logger.warning("Alipay callback signature verification FAILED") else: logger.info("Alipay callback signature verification SUCCESS") return is_valid except Exception: logger.exception("Alipay callback verification error") return False def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type: str = "RSA2") -> bool: """Verify Alipay RSA/RSA2 signature. Args: public_key: Alipay public key (PEM format, with or without headers) sign_content: Original content to verify sign: Base64 encoded signature sign_type: "RSA" (SHA1) or "RSA2" (SHA256) Returns: True if signature is valid """ try: import base64 from hashlib import sha1, sha256 # 处理公钥,确保有正确的格式 pub_key = public_key.strip() if not pub_key.startswith("-----BEGIN"): pub_key = "-----BEGIN PUBLIC KEY-----\n" + pub_key + "\n-----END PUBLIC KEY-----" try: from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend # 加载公钥 public_key_obj = serialization.load_pem_public_key( pub_key.encode("utf-8"), backend=default_backend() ) # 选择哈希算法 if sign_type == "RSA2": hash_alg = hashes.SHA256() else: hash_alg = hashes.SHA1() # 验证签名 public_key_obj.verify( base64.b64decode(sign), sign_content.encode("utf-8"), padding.PKCS1v15(), hash_alg ) return True except ImportError: # 如果没有 cryptography,尝试使用 rsa 库 try: import rsa # 加载公钥 pub_key_obj = rsa.PublicKey.load_pkcs1_openssl_pem(pub_key.encode("utf-8")) # 选择哈希算法 if sign_type == "RSA2": hash_func = 'SHA-256' else: hash_func = 'SHA-1' # 验证签名 rsa.verify( sign_content.encode("utf-8"), base64.b64decode(sign), pub_key_obj, hash_func ) return True except ImportError: logger.error("Neither cryptography nor rsa library installed, cannot verify signature") # 如果没有任何加密库,在生产环境应该返回 False,但这里我们记录警告并继续 logger.warning("Skipping signature verification due to missing crypto libraries") return False except Exception as e: logger.exception(f"Signature verification failed: {e}") return False # --------------------------------------------------------------------------- # WeChat callback verification (stub) # --------------------------------------------------------------------------- async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool: """Verify WeChat payment callback signature. Note: Since we're using wechatpayv3 SDK which handles verification internally, in the callback handler we'll verify with the SDK. This function is kept for interface consistency and mock mode support. """ db_configs = await _get_payment_configs(db) mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info("Mock mode: skipping WeChat callback verification") return True # 对于真实模式,我们在回调路由处理器中直接使用 SDK 验证 # 这里我们返回 True 以保持接口一致性 logger.info("WeChat callback verification (delegated to SDK in router)") 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).with_for_update().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.commit() async def process_payment_success_by_order_no( db: AsyncSession, order_no: str, trade_no: str = "", total_amount: float | None = None ): """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 total_amount: the payment amount from the gateway, for consistency check """ result = await db.execute( select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1) ) order = result.scalar_one_or_none() if not order: logger.info(f"Order {order_no} not found, skipping") return if order.status == "paid": logger.info(f"Order {order_no} already processed, skipping") return if order.status != "pending": logger.info(f"Order {order_no} is in {order.status} state, cannot process") return # 金额一致性校验 if total_amount is not None and abs(total_amount - order.amount) > 0.01: logger.error( f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}" ) return # 幂等性检查:如果trade_no已存在且相同,则跳过 if trade_no and order.trade_no and order.trade_no == trade_no: logger.info(f"Trade no {trade_no} 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.commit() logger.info( f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} " f"amount={order.amount} credits={order.credits} trade_no={trade_no}" ) async def process_refund( db: AsyncSession, order_no: str, refund_amount: float | None = None, refund_reason: str = "管理员退款" ) -> dict: """Process a refund for a paid order. Args: db: async database session order_no: merchant order number refund_amount: amount to refund (defaults to full order amount) refund_reason: reason for refund Returns: dict with refund result """ result = await db.execute( select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1) ) order = result.scalar_one_or_none() if not order: return {"success": False, "message": "订单不存在"} if order.status != "paid": return {"success": False, "message": f"订单状态为{order.status},无法退款"} if order.refunded_at is not None: return {"success": False, "message": "订单已退款"} refund_amount = refund_amount or order.amount # 金额校验 if refund_amount > order.amount: return {"success": False, "message": "退款金额超过订单金额"} # 根据支付方式调用相应的退款API db_configs = await _get_payment_configs(db) if order.payment_method == "alipay": refund_result = await _refund_alipay_order( db, order, refund_amount, refund_reason, db_configs ) if not refund_result.get("success"): return refund_result elif order.payment_method == "wechat": refund_result = await _refund_wechat_order( db, order, refund_amount, refund_reason, db_configs ) if not refund_result.get("success"): return refund_result # 扣除积分 try: await deduct_credits( db, order.user_id, order.credits, refund_reason, related_id=order.id, ) except Exception as e: logger.exception(f"Failed to deduct credits for refund: {e}") return {"success": False, "message": "积分扣除失败"} # 更新订单状态 order.status = "refunded" order.refund_amount = refund_amount order.refunded_at = datetime.now() if order.payment_method == "alipay": order.refund_trade_no = db_configs.get("refund_trade_no", "") await db.commit() logger.info( f"REFUND_SUCCESS order_no={order_no} user={order.user_id} " f"refund_amount={refund_amount}" ) return {"success": True, "message": "退款成功"} async def _refund_alipay_order( db: AsyncSession, order: PaymentOrder, refund_amount: float, refund_reason: str, db_configs: dict[str, str] ) -> dict: """Call Alipay refund API.""" 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", "") client = _get_alipay_client(app_id, private_key, public_key, gateway) if client is None: return {"success": False, "message": "支付宝客户端初始化失败"} mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info(f"Mock mode: skipping alipay refund for {order.order_no}") return {"success": True} try: from alipay.aop.api.domain.AlipayTradeRefundModel import AlipayTradeRefundModel from alipay.aop.api.request.AlipayTradeRefundRequest import AlipayTradeRefundRequest from alipay.aop.api.response.AlipayTradeRefundResponse import AlipayTradeRefundResponse model = AlipayTradeRefundModel() model.out_trade_no = order.order_no model.refund_amount = f"{refund_amount:.2f}" model.refund_reason = refund_reason model.out_request_no = f"{order.order_no}_refund_{int(datetime.now().timestamp())}" request = AlipayTradeRefundRequest(biz_model=model) response_content = client.execute(request) if not response_content: logger.error(f"Alipay refund failed: empty response, order_no={order.order_no}") return {"success": False, "message": "支付宝退款响应为空"} response = AlipayTradeRefundResponse() response.parse_response_content(response_content) if response.is_success(): logger.info(f"Alipay refund succeeded: order_no={order.order_no}") return {"success": True, "trade_no": response.trade_no} else: logger.error( f"Alipay refund 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 { "success": False, "message": f"支付宝退款失败: {response.sub_msg or response.msg}" } except Exception as e: logger.exception(f"Alipay refund exception: order_no={order.order_no}, {e}") return {"success": False, "message": f"支付宝退款异常: {str(e)}"}