1302 lines
49 KiB
Python
1302 lines
49 KiB
Python
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.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
|
||
|
||
|
||
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.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]) -> 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.
|
||
|
||
微信退款是异步的,调用后会返回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
|
||
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}积分), 订单号: {order_no}, 金额: {order.amount}",
|
||
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)}"}
|