import logging import os import json from datetime import datetime, timedelta from enum import Enum # 尝试设置 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.credit.product import CreditProduct from app.models.user import User from app.models.system_config import SystemConfig from app.enums.credit_record import ( CreditRecordAction, CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule, CreditRecordSubject, ) from app.services.credit_record_meta_service import CreditRecordMeta, build_recharge_meta from app.services.credits import add_credits, deduct_credits from app.enums.common import PaymentOrderSourceEnum from app.enums.credit_product import CreditProductType from app.models.team import Team from app.services.credit.locking import acquire_team_business_lock, acquire_user_credit_lock from app.services.credit.product_service import ensure_repeat_purchase_allowed, quote_product, resolve_team_purchase_context from app.services.credit.subscription_service import fulfill_payment_product from app.services.credit.utils import to_credit_decimal, utc_now from app.utils.id_gen import generate_id, generate_order_no def _payment_biz_key(order: PaymentOrder, *, charge_kind: str, action: str) -> str: return ( f"{CreditRecordOwnerType.PAYMENT_ORDER.value}:{order.id}:" f"attempt:1:{charge_kind}:{action}" ) def _payment_recharge_meta(order: PaymentOrder) -> CreditRecordMeta: meta = build_recharge_meta(owner_id=order.id) meta.attempt_no = 1 return meta def _payment_refund_meta(order: PaymentOrder) -> CreditRecordMeta: return CreditRecordMeta( owner_type=CreditRecordOwnerType.PAYMENT_ORDER.value, owner_id=order.id, attempt_no=1, charge_kind=CreditRecordChargeKind.REFUND.value, charge_action=CreditRecordAction.REFUND.value, credit_subject=CreditRecordSubject.REFUND.value, billing_scene=CreditRecordBillingScene.REFUND.value, source_module=CreditRecordSourceModule.PAYMENT.value, ) # --------------------------------------------------------------------------- # 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.DEBUG) _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 class PaymentCloseResult(str, Enum): """Result of attempting to close a remote payment order. ``PAID`` is intentionally distinct from ``FAILED``: when a gateway explicitly says an order is already paid, callers must recover the paid transaction instead of cancelling locally or retrying close forever. """ CLOSED = "closed" PAID = "paid" FAILED = "failed" 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 _close_order_for_cancellation( db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str], ) -> PaymentCloseResult: """Close a gateway order before changing the local order to cancelled. A gateway may explicitly report that the order has already been paid. That is not a close failure: it must be routed into paid-order recovery so the normal payment fulfillment pipeline can run. """ if order.payment_method == "alipay": closed = await _close_alipay_order(db, order, db_configs) return PaymentCloseResult.CLOSED if closed else PaymentCloseResult.FAILED if order.payment_method == "wechat": return await _close_wechat_order(db, order, db_configs) return PaymentCloseResult.CLOSED async def _resolve_order_for_cancellation( db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str], ) -> PaymentCloseResult: """Resolve a cancellation attempt into closed / paid / failed. Normal orders are simply closed. For WeChat ``ORDERPAID``, query only this order once and feed the verified result into the existing atomic payment-success handler. """ close_result = await _close_order_for_cancellation(db, order, db_configs) if close_result != PaymentCloseResult.PAID: return close_result if order.payment_method != "wechat": return PaymentCloseResult.FAILED recovered = await _recover_wechat_paid_order(db, order, db_configs) return PaymentCloseResult.PAID if recovered else PaymentCloseResult.FAILED async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool: """Expire one pending order after resolving the remote gateway state. If WeChat reports ``ORDERPAID``, the order is queried once and recovered through the normal payment-success pipeline instead of being cancelled. """ 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 utc_now() < expiry: return False log_order_no = str(order.order_no) log_user_id = str(order.user_id) log_amount = order.amount close_result = await _resolve_order_for_cancellation(db, order, db_configs) if close_result == PaymentCloseResult.PAID: logger.info( f"ORDER_EXPIRE_PAID_RECOVERED order_no={log_order_no} " f"user={log_user_id} amount={log_amount}" ) return False if close_result != PaymentCloseResult.CLOSED: logger.warning( f"ORDER_EXPIRE_CLOSE_PENDING order_no={log_order_no} " f"user={log_user_id} amount={log_amount}" ) return False 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 async def expire_all_pending_orders(db: AsyncSession) -> int: """Background task: resolve and expire overdue pending orders. Each order is handled in its own database transaction. This keeps one gateway or fulfillment failure from invalidating ORM state for the rest of the batch. A WeChat ``ORDERPAID`` result is recovered as a paid order, never persisted as cancelled. """ db_configs = await _get_payment_configs(db) expire_seconds = _get_order_expire_seconds(db_configs) threshold = utc_now() - timedelta(seconds=expire_seconds) result = await db.execute( select(PaymentOrder.order_no).where( PaymentOrder.status == "pending", PaymentOrder.created_at <= threshold, ) ) order_nos = [str(row[0]) for row in result.all()] # End the read-only candidate transaction before processing individual # orders. Subsequent commit/rollback never leaves us reusing stale ORM # instances from this initial scan. await db.rollback() expired_count = 0 for order_no in order_nos: try: result = await db.execute( select(PaymentOrder) .where( PaymentOrder.order_no == order_no, PaymentOrder.status == "pending", ) .with_for_update() .limit(1) ) order = result.scalar_one_or_none() if order is None: await db.rollback() continue close_result = await _resolve_order_for_cancellation( db, order, db_configs ) if close_result == PaymentCloseResult.PAID: logger.info( f"ORDER_EXPIRE_PAID_RECOVERED order_no={order_no}" ) # Paid recovery commits inside process_payment_success_by_order_no. continue if close_result != PaymentCloseResult.CLOSED: logger.warning( f"ORDER_EXPIRE_CLOSE_PENDING order_no={order_no} " f"user={order.user_id} amount={order.amount}" ) await db.rollback() continue log_user_id = str(order.user_id) log_amount = order.amount order.status = "cancelled" await db.commit() expired_count += 1 logger.info( f"ORDER_EXPIRED order_no={order_no} user={log_user_id} " f"amount={log_amount}" ) except Exception: await db.rollback() logger.exception( f"ORDER_EXPIRE_PROCESS_FAILED order_no={order_no}" ) 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 = 0.0, price: float = 0.0, label: str = "积分商品", bonus_credits: float = 0.0, method: str = "wechat", *, product_id: str | None = None, quantity: int = 1, request_time: datetime | None = None, ) -> PaymentOrder: """创建线上支付订单。 商品订单在创建时冻结商品、定价和数量快照;之后商品改价、下架或软删除均不改变订单合同。 个人/团队订阅存在未完成订单时全局阻止再次创建订阅订单。 """ db_configs = await _get_payment_configs(db) mock_mode = _is_mock_mode(db_configs) 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 = [key for key in required_configs if not db_configs.get(key)] if missing_configs: raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员") else: raise ValueError("不支持的线上支付方式") checked_at = request_time or utc_now() await acquire_user_credit_lock(db, user_id) user_result = await db.execute(select(User).where(User.id == user_id).limit(1)) user = user_result.scalar_one_or_none() if user is None: raise ValueError("用户不存在") order_id = generate_id() order_no = generate_order_no() product: CreditProduct | None = None final_price = to_credit_decimal(price) total_credits = to_credit_decimal(float(credits or 0) + float(bonus_credits or 0)) snapshot = None product_type = None team_id_snapshot = None quoted_unit = final_price quoted_amount = final_price actual_unit = final_price purchase_scene = "legacy_recharge" price_type = "regular" if product_id: product_result = await db.execute( select(CreditProduct) .where( CreditProduct.id == product_id, CreditProduct.deleted_at.is_(None), CreditProduct.is_active.is_(True), ) .limit(1) ) product = product_result.scalar_one_or_none() if product is None: raise ValueError("积分商品不存在、已下架或已删除") product_type = product.product_type if product_type == CreditProductType.TEAM_SUBSCRIPTION.value: if not 2 <= int(quantity) <= 20: raise ValueError("客户端团队套餐单次购买数量必须在2到20之间") team, available, reason = await resolve_team_purchase_context(db, user=user) if not available: raise ValueError(reason or "当前不能购买团队订阅套餐") if team is not None: await acquire_team_business_lock(db, team.id) team, available, reason = await resolve_team_purchase_context(db, user=user) if not available or team is None: raise ValueError(reason or "当前不能购买团队订阅套餐") team_id_snapshot = team.id if team else None first_purchase = bool(team is None or team.first_subscription_paid_at is None) else: if int(quantity) != 1: raise ValueError("个人订阅和积分增值包购买数量固定为1") quantity = 1 first_purchase = user.first_membership_paid_at is None if product_type == CreditProductType.SUBSCRIPTION.value else False if product_type in {CreditProductType.SUBSCRIPTION.value, CreditProductType.TEAM_SUBSCRIPTION.value}: ensure_repeat_purchase_allowed(product, first_purchase=first_purchase) incomplete_result = await db.execute( select(PaymentOrder.id).where( PaymentOrder.user_id == user_id, PaymentOrder.product_type.in_([ CreditProductType.SUBSCRIPTION.value, CreditProductType.TEAM_SUBSCRIPTION.value, ]), ( (PaymentOrder.status == "pending") | ((PaymentOrder.status == "paid") & (PaymentOrder.fulfillment_status != "fulfilled")) ), ).limit(1) ) if incomplete_result.scalar_one_or_none(): raise ValueError("存在未完成的订阅订单,请先前往订单记录完成支付或处理原订单") quote = quote_product( product, first_purchase=first_purchase, request_time=checked_at, quantity=int(quantity), ) quoted_unit = quote.quoted_unit_price quoted_amount = quote.quoted_amount final_price = quoted_amount actual_unit = quoted_unit purchase_scene = quote.purchase_scene price_type = quote.price_type label = product.name if product_type == CreditProductType.TEAM_SUBSCRIPTION.value: total_credits = to_credit_decimal((product.monthly_grant_credits or 0) * int(quantity)) elif product_type == CreditProductType.SUBSCRIPTION.value: total_credits = to_credit_decimal(product.monthly_grant_credits or 0) else: total_credits = to_credit_decimal(product.grant_credits or 0) snapshot = { "id": product.id, "product_code": product.product_code, "product_type": product.product_type, "name": product.name, "description": product.description, "features": product.features_json or [], "tier_code": product.tier_code, "tier_rank": product.tier_rank, "billing_cycle": product.billing_cycle, "monthly_grant_credits": float(product.monthly_grant_credits or 0), "first_purchase_price": float(product.first_purchase_price or 0), "regular_price": float(product.regular_price or 0), "activity_price": float(product.activity_price) if product.activity_price is not None else None, "activity_start_at": product.activity_start_at.isoformat() if product.activity_start_at else None, "activity_end_at": product.activity_end_at.isoformat() if product.activity_end_at else None, "renewal_enabled": bool(product.renewal_enabled), "grant_credits": float(product.grant_credits or 0), "validity_months": product.validity_months, "credit_level": product.credit_level, "currency": product.currency, } # 固定锁序:user advisory 已获取;团队订单已先获取 Team advisory,最后才锁 User 行。 locked_user_result = await db.execute(select(User).where(User.id == user_id).limit(1).with_for_update()) locked_user = locked_user_result.scalar_one_or_none() if locked_user is None: raise ValueError("用户不存在") if locked_user.team_id != user.team_id: raise ValueError("用户团队关系已发生变化,请刷新后重试") user = locked_user order = PaymentOrder( id=order_id, user_id=user_id, order_no=order_no, amount=final_price, credits=total_credits, payment_method=method, order_source=PaymentOrderSourceEnum.ONLINE_PAYMENT.value, status="pending", product_id=product.id if product else None, product_type=product_type, purchase_scene=purchase_scene, price_type=price_type, product_code_snapshot=product.product_code if product else None, product_name_snapshot=label, product_snapshot_json=snapshot, quantity=int(quantity), quoted_unit_price_snapshot=quoted_unit, quoted_amount_snapshot=quoted_amount, actual_unit_price_snapshot=actual_unit, team_id_snapshot=team_id_snapshot, fulfillment_status="pending" if product else None, ) db.add(order) await db.flush() logger.info( f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={order.amount} " f"credits={order.credits} method={method} product_id={product_id} quantity={quantity} mock={mock_mode}" ) if mock_mode: order.status = "paid" order.paid_at = checked_at desc = f"充值{label}({order.credits}积分)" await _fulfill_paid_order(db, order=order, fulfilled_at=checked_at, legacy_description=desc) await db.flush() elif method == "wechat": qr_code_content = _create_wechat_order(order, db_configs) if not qr_code_content: raise ValueError("微信支付预下单失败,请检查配置或稍后重试") order.qr_url = qr_code_content # type: ignore[attr-defined] elif method == "alipay": qr_url = _create_alipay_order(order, db_configs) if not qr_url: raise ValueError("支付宝预下单失败,请检查配置或稍后重试") order.qr_url = qr_url # type: ignore[attr-defined] 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.credits},订单 {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], ) -> PaymentCloseResult: """Call WeChat Pay close API and classify the authoritative result.""" 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 PaymentCloseResult.FAILED mock_mode = _is_mock_mode(db_configs) if mock_mode: logger.info(f"Mock mode: skipping close_wechat_order for {order.order_no}") return PaymentCloseResult.CLOSED 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 PaymentCloseResult.CLOSED data = _parse_wechat_result(result) error_code = str( data.get("code") or data.get("err_code") or data.get("error_code") or "" ).upper() raw_result = str(result).upper() # 微信明确返回“订单不存在”时,同样视为没有远端订单需要继续关闭。 # 只兼容明确的订单不存在错误,不吞掉签名、网络、系统等其他异常。 if error_code in {"ORDER_NOT_EXIST", "ORDERNOTEXIST"} or ( code == 404 and ("ORDER_NOT_EXIST" in raw_result or "ORDERNOTEXIST" in raw_result) ): logger.info( f"WeChat order not exist, treat as closed for local expiry: " f"order_no={order.order_no}, code={code}, error_code={error_code or 'unknown'}" ) return PaymentCloseResult.CLOSED # ORDERPAID means the remote transaction is already paid. Do not # cancel locally and do not retry close forever; the caller will query # this one order once and recover it through the normal paid pipeline. if error_code == "ORDERPAID" or "ORDERPAID" in raw_result: logger.info( f"WeChat close reports order already paid: " f"order_no={order.order_no}, code={code}" ) return PaymentCloseResult.PAID logger.error( f"WeChat close failed: order_no={order.order_no}, " f"code={code}, result={result}" ) return PaymentCloseResult.FAILED except Exception as e: logger.exception(f"WeChat close exception: order_no={order.order_no}") return PaymentCloseResult.FAILED 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 _recover_wechat_paid_order( db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str], ) -> bool: """Recover one WeChat order after close reports ``ORDERPAID``. This is not a periodic query of all pending orders. It runs only after WeChat itself explicitly says this specific order has already been paid. The query supplies the authoritative transaction id and amount, then the existing atomic payment-success handler performs all subscription / upgrade / credit fulfillment. """ local_order_no = str(order.order_no) data = await _query_wechat_order(db, order, db_configs) if not data: logger.warning( f"WECHAT_ORDERPAID_QUERY_FAILED order_no={local_order_no}" ) return False trade_state = str(data.get("trade_state") or "").upper() remote_order_no = str(data.get("out_trade_no") or "") transaction_id = str(data.get("transaction_id") or "") amount_info = data.get("amount") or {} currency = str(amount_info.get("currency") or "CNY").upper() total_fen = amount_info.get("total") if trade_state != "SUCCESS": logger.warning( f"WECHAT_ORDERPAID_QUERY_NOT_SUCCESS order_no={local_order_no} " f"trade_state={trade_state or 'unknown'}" ) return False if remote_order_no and remote_order_no != local_order_no: logger.error( f"WECHAT_ORDERPAID_ORDER_MISMATCH local={local_order_no} " f"remote={remote_order_no}" ) return False if currency != "CNY": logger.error( f"WECHAT_ORDERPAID_CURRENCY_MISMATCH order_no={local_order_no} " f"currency={currency}" ) return False if total_fen is None: logger.error( f"WECHAT_ORDERPAID_AMOUNT_MISSING order_no={local_order_no}" ) return False try: total_amount = float(to_credit_decimal(total_fen) / to_credit_decimal(100)) except Exception: logger.exception( f"WECHAT_ORDERPAID_AMOUNT_INVALID order_no={local_order_no} " f"total={total_fen}" ) return False if not transaction_id: logger.error( f"WECHAT_ORDERPAID_TRANSACTION_ID_MISSING order_no={local_order_no}" ) return False processed = await process_payment_success_by_order_no( db, local_order_no, transaction_id, total_amount, ) if processed: logger.info( f"WECHAT_ORDERPAID_RECOVERED order_no={local_order_no} " f"transaction_id={transaction_id} amount={total_amount}" ) else: logger.warning( f"WECHAT_ORDERPAID_RECOVERY_NOT_PROCESSED order_no={local_order_no} " f"transaction_id={transaction_id} amount={total_amount}" ) return processed 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. 微信退款是异步的,调用后会返回PROCESSING状态,实际退款结果通过回调通知。 """ 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) # 微信退款是异步的,PROCESSING是正常状态,表示退款已受理 if code == 200: status = data.get('status') if status in ('SUCCESS', 'PROCESSING', 'REFUNDCLOSE'): logger.info( f"WeChat refund initiated: order_no={order.order_no}, " f"status={status}, refund_id={data.get('refund_id')}" ) return { "success": True, "refund_id": data.get('refund_id'), "status": status, "message": "退款申请已提交,等待微信处理" } 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('message', '')}" } 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.credits},订单 {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 # 支付宝明确返回“交易不存在”时,远端没有可继续关闭的交易。 # 对本地已经达到超时时间的 pending 订单,可视为关单目标已经满足, # 避免 Celery 每分钟对同一订单无限重复调用 trade.close。 if str(response.sub_code or "").upper() == "ACQ.TRADE_NOT_EXIST": logger.info( f"Alipay trade not exist, treat as closed for local expiry: " f"order_no={order.order_no}, code={response.code}, " f"sub_code={response.sub_code}" ) return True 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. Candidate rows are collected as primitive values first. A failed payment fulfillment may roll back the session; using primitive candidates prevents that rollback from expiring ORM objects needed by later iterations. """ result = await db.execute( select(PaymentOrder.order_no, PaymentOrder.payment_method).where( PaymentOrder.status == "pending", ) ) candidates = [(str(row.order_no), str(row.payment_method)) for row in result.all()] updated_count = 0 db_configs = await _get_payment_configs(db) for order_no, payment_method in candidates: try: order_result = await db.execute( select(PaymentOrder).where( PaymentOrder.order_no == order_no, PaymentOrder.status == "pending", ).limit(1) ) order = order_result.scalar_one_or_none() if order is None: continue if 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"): 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 processed = await process_payment_success_by_order_no( db, order_no, trade_no, total_amount ) if processed: updated_count += 1 elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"): order.status = "cancelled" await db.commit() updated_count += 1 elif payment_method == "wechat": data = await _query_wechat_order(db, order, db_configs) if data: trade_state = data.get("trade_state") if trade_state == "SUCCESS": transaction_id = data.get("transaction_id", "") total_amount = float(data.get("amount", {}).get("total", 0)) / 100 processed = await process_payment_success_by_order_no( db, order_no, transaction_id, total_amount ) if processed: updated_count += 1 elif trade_state in ("CLOSED", "REVOKED"): order.status = "cancelled" await db.commit() updated_count += 1 except Exception as e: await db.rollback() logger.exception(f"Failed to sync order {order_no}: {e}") 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 _fulfill_paid_order( db: AsyncSession, *, order: PaymentOrder, fulfilled_at: datetime, legacy_description: str, ) -> None: """Fulfill the business side of a paid order without committing. Product orders are considered successfully paid locally only when the product service leaves the order in the explicit ``fulfilled`` state. This turns any internal upgrade/reconciliation failure into an exception so the outer payment transaction can roll back the temporary ``paid`` state. """ if order.product_id: await fulfill_payment_product(db, order=order, fulfilled_at=fulfilled_at) if order.fulfillment_status != "fulfilled": raise RuntimeError( f"Payment product fulfillment incomplete: order_no={order.order_no}, " f"fulfillment_status={order.fulfillment_status}" ) return await add_credits( db, order.user_id, order.credits, legacy_description, related_id=order.id, biz_key=_payment_biz_key( order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value, ), record_meta=_payment_recharge_meta(order), payment_order_id=order.id, source_id=order.id, ) async def process_payment_success(db: AsyncSession, order_id: str) -> bool: """Atomically mark a pending order paid and fulfill its business value.""" try: 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: return False if order.status == "paid": return True if order.status != "pending": return False paid_at = utc_now() order.status = "paid" order.paid_at = paid_at await _fulfill_paid_order( db, order=order, fulfilled_at=paid_at, legacy_description=f"充值成功({order.credits}积分)", ) await db.commit() return True except Exception: await db.rollback() raise async def process_payment_success_by_order_no( db: AsyncSession, order_no: str, trade_no: str = "", total_amount: float | None = None, ) -> bool: """Atomically process an Alipay/WeChat payment success. ``paid`` is persisted only together with successful local fulfillment. When fulfillment fails, the whole local transaction is rolled back and the order remains ``pending``; the existing gateway query loop may retry it. """ try: 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 False if order.status == "paid": logger.info(f"Order {order_no} already processed, skipping") return True if order.status != "pending": logger.info(f"Order {order_no} is in {order.status} state, cannot process") return False if ( total_amount is not None and abs( to_credit_decimal(total_amount) - to_credit_decimal(order.amount) ) > to_credit_decimal("0.01") ): logger.error( f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}" ) return False # A pending order must never be skipped merely because trade_no is # already present. Historical partial writes can otherwise remain # pending forever. The row lock + paid state + credit biz_key provide # the actual idempotency boundary. paid_at = utc_now() order.status = "paid" order.paid_at = paid_at if trade_no: order.trade_no = trade_no await _fulfill_paid_order( db, order=order, fulfilled_at=paid_at, legacy_description=( f"充值成功({order.credits}积分), " f"订单号: {order_no}, 金额: {order.amount}" ), ) log_user_id = str(order.user_id) log_amount = order.amount log_credits = order.credits await db.commit() logger.info( f"PAYMENT_SUCCESS order_no={order_no} user={log_user_id} " f"amount={log_amount} credits={log_credits} trade_no={trade_no}" ) return True except Exception: await db.rollback() raise async def process_refund( db: AsyncSession, order_no: str, refund_amount: float | None = None, refund_reason: str = "管理员退款", ) -> dict: """本版本保留退款 Service 入口,但主动订单退款统一关闭。""" result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)) order = result.scalar_one_or_none() if not order: return {"success": False, "message": "订单不存在"} logger.warning( "REFUND_BLOCKED order_no=%s user=%s requested_amount=%s reason=%s", order_no, order.user_id, refund_amount, refund_reason, ) return {"success": False, "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)}"}