Files
video-gen/video-gen-api/app/services/payment.py
T
2026-08-11 15:38:53 +08:00

1740 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.services.credit.product_service import product_to_dict
from app.services.credit.subscription_service import fulfill_payment_product, revoke_payment_order_credits
from app.services.credit.upgrade_service import quote_and_reserve_product_purchase, release_upgrade_reservation
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"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now())
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"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
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,
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)},请联系管理员")
checked_at = request_time or utc_now()
order_id = generate_id()
order_no = generate_order_no()
product: CreditProduct | None = None
quote = None
# 先持久化订单主记录,再做升级周期预留。订阅周期的 upgrade_order_id
# 有外键约束,若先更新周期后插入订单,flush 顺序可能触发外键异常。
order = PaymentOrder(
id=order_id,
user_id=user_id,
order_no=order_no,
amount=price,
credits=round(float(credits or 0) + float(bonus_credits or 0), 2),
payment_method=method,
status="pending",
purchase_scene="legacy_recharge",
price_type="regular",
product_name_snapshot=label,
target_price_snapshot=price,
deduction_amount_snapshot=0,
payable_amount_snapshot=price,
)
db.add(order)
await db.flush()
if product_id:
product_result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id, CreditProduct.is_active.is_(True)).limit(1)
)
product = product_result.scalar_one_or_none()
if product is None:
raise ValueError("积分商品不存在或已下架")
# 订阅报价服务内部先获取用户级 advisory lock,再按统一顺序锁订阅周期。
# 此处不预先锁 users 行,避免与支付履约(advisory -> users)形成反向锁序。
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("用户不存在")
quote = await quote_and_reserve_product_purchase(
db, user=user, product=product, order_id=order.id, request_time=checked_at
)
price = float(quote.payable_amount)
label = product.name
credits = float(product.grant_credits or product.monthly_grant_credits or 0)
bonus_credits = 0.0
order.amount = quote.payable_amount
order.credits = round(float(credits or 0), 2)
order.product_id = product.id
order.product_type = product.product_type
order.purchase_scene = quote.purchase_scene
order.price_type = quote.price_type
order.product_code_snapshot = product.product_code
order.product_name_snapshot = product.name
product_snapshot = product_to_dict(product)
for time_key in ("activity_start_at", "activity_end_at"):
value = product_snapshot.get(time_key)
if value is not None:
product_snapshot[time_key] = value.isoformat()
order.product_snapshot_json = product_snapshot
order.source_subscription_id = quote.source_subscription_id
order.upgrade_period_ids_json = list(quote.upgrade_period_ids) or None
order.target_price_snapshot = quote.target_price
order.deduction_amount_snapshot = quote.deduction_amount
order.payable_amount_snapshot = quote.payable_amount
order.fulfillment_status = "pending"
total_credits = round(float(credits or 0) + float(bonus_credits or 0), 2)
await db.flush()
logger.info(
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={price} "
f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}"
)
if mock_mode:
order.status = "paid"
order.paid_at = checked_at
desc = f"充值{label}({total_credits}积分)"
if bonus_credits > 0:
desc += f"(含赠送{bonus_credits}积分)"
await _fulfill_paid_order(
db,
order=order,
fulfilled_at=checked_at,
legacy_description=desc,
)
await db.flush()
else:
if method == "wechat":
qr_code_content = _create_wechat_order(order, db_configs)
if qr_code_content:
order.qr_url = qr_code_content # type: ignore[attr-defined]
else:
if quote and quote.upgrade_period_ids:
await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
elif method == "alipay":
qr_url = _create_alipay_order(order, db_configs)
if qr_url:
order.qr_url = qr_url # type: ignore[attr-defined]
else:
if quote and quote.upgrade_period_ids:
await release_upgrade_reservation(db, order=order, released_at=checked_at)
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],
) -> 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"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
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"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
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:
"""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 = to_credit_decimal(refund_amount if refund_amount is not None else order.amount)
# 金额校验
if refund_amount > to_credit_decimal(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:
if order.product_id:
await revoke_payment_order_credits(db, order=order, reason=refund_reason)
else:
await deduct_credits(
db,
order.user_id,
order.credits,
refund_reason,
related_id=order.id,
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.REFUND.value, action=CreditRecordAction.REFUND.value),
refund_for_biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
record_meta=_payment_refund_meta(order),
)
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 = utc_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)}"}