Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
@@ -1,15 +1,22 @@
|
||||
import logging
|
||||
import os
|
||||
import certifi
|
||||
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
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.utils.id_gen import generate_id, generate_order_no
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -61,8 +68,50 @@ _handler.setFormatter(logging.Formatter(
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
|
||||
# Orders pending payment for longer than this are auto-cancelled
|
||||
ORDER_EXPIRE_MINUTES = 5
|
||||
# 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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -84,7 +133,9 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
|
||||
"""
|
||||
if order.status != "pending":
|
||||
return False
|
||||
expiry = order.created_at + timedelta(minutes=ORDER_EXPIRE_MINUTES)
|
||||
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()
|
||||
@@ -92,6 +143,12 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
|
||||
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
|
||||
f"amount={order.amount} created_at={order.created_at.isoformat()}"
|
||||
)
|
||||
# Also call Alipay close API if it was an Alipay 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}")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -100,7 +157,9 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
"""Background task: mark all expired pending orders as cancelled.
|
||||
Returns the number of orders expired.
|
||||
"""
|
||||
threshold = datetime.now() - timedelta(minutes=ORDER_EXPIRE_MINUTES)
|
||||
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",
|
||||
@@ -108,14 +167,22 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
)
|
||||
)
|
||||
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 Alipay close API if it was an Alipay 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}")
|
||||
if orders:
|
||||
await db.flush()
|
||||
return len(orders)
|
||||
return expired_count
|
||||
|
||||
|
||||
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
|
||||
@@ -157,15 +224,9 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway:
|
||||
config.alipay_public_key = public_key
|
||||
config.sign_type = "RSA2"
|
||||
config.charset = "utf-8"
|
||||
config.cert_path = certifi.where()
|
||||
logger.info(
|
||||
f"Initializing Alipay client: app_id={app_id}, gateway={config.server_url}, "
|
||||
f"public_key={config.alipay_public_key}, "
|
||||
f"private_key={config.app_private_key}, "
|
||||
f"cert_path={config.cert_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
_alipay_client = DefaultAlipayClient(config)
|
||||
_alipay_client = DefaultAlipayClient(config, logger)
|
||||
_alipay_client_app_id = app_id
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize Alipay client")
|
||||
@@ -291,8 +352,8 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
|
||||
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", "")
|
||||
notify_url = db_configs.get("payment_alipay_notify_url", "")
|
||||
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)")
|
||||
@@ -309,11 +370,16 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
|
||||
from alipay.aop.api.request.AlipayTradePrecreateRequest import (
|
||||
AlipayTradePrecreateRequest,
|
||||
)
|
||||
from alipay.aop.api.response.AlipayTradePrecreateResponse import (
|
||||
AlipayTradePrecreateResponse,
|
||||
)
|
||||
|
||||
# 构造业务参数
|
||||
model = AlipayTradePrecreateModel()
|
||||
model.out_trade_no = order.order_no
|
||||
model.total_amount = f"{order.amount:.2f}"
|
||||
model.subject = f"充值订单 {order.order_no}"
|
||||
model.product_code = "QR_CODE_OFFLINE"
|
||||
|
||||
body_parts = []
|
||||
if order.credits > 0:
|
||||
@@ -321,19 +387,31 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
|
||||
if body_parts:
|
||||
model.body = " ".join(body_parts)
|
||||
|
||||
request = AlipayTradePrecreateRequest()
|
||||
request.biz_model = model
|
||||
# 构造请求
|
||||
request = AlipayTradePrecreateRequest(biz_model=model)
|
||||
|
||||
# 设置 notify_url 在 request 上
|
||||
if notify_url:
|
||||
request.notify_url = 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}")
|
||||
|
||||
response = client.execute(request)
|
||||
# 执行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
|
||||
|
||||
if response.code == "10000":
|
||||
# 解析响应结果
|
||||
response = AlipayTradePrecreateResponse()
|
||||
response.parse_response_content(response_content)
|
||||
|
||||
if response.is_success():
|
||||
qr_url = response.qr_code
|
||||
logger.info(
|
||||
f"Alipay precreate success: order_no={order.order_no}, "
|
||||
f"qr_url={qr_url}"
|
||||
)
|
||||
return qr_url
|
||||
else:
|
||||
logger.error(
|
||||
@@ -343,11 +421,186 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
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 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:
|
||||
if order.payment_method != "alipay":
|
||||
continue
|
||||
|
||||
try:
|
||||
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", "")
|
||||
await process_payment_success_by_order_no(db, order.order_no, trade_no)
|
||||
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
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,12 +609,12 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
|
||||
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 the SDK's
|
||||
built-in RSA2 verification.
|
||||
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", "")
|
||||
@@ -375,37 +628,119 @@ async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
|
||||
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 != ""
|
||||
}
|
||||
|
||||
from alipay.aop.api.util.Signature import verify_with_rsa
|
||||
|
||||
# Generate sign content: sorted keys, key=value format
|
||||
sign_content = "&".join(
|
||||
f"{k}={v}" for k, v in sorted(verify_data.items())
|
||||
)
|
||||
|
||||
is_valid = verify_with_rsa(
|
||||
public_key.encode("utf-8"),
|
||||
sign_content.encode("utf-8"),
|
||||
sign,
|
||||
)
|
||||
# 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 ImportError:
|
||||
logger.error("alipay-sdk-python not installed, skipping signature verification")
|
||||
return True
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -429,7 +764,7 @@ async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
|
||||
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).limit(1)
|
||||
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":
|
||||
@@ -444,23 +779,50 @@ async def process_payment_success(db: AsyncSession, order_id: str):
|
||||
f"充值成功({order.credits}积分)",
|
||||
related_id=order.id,
|
||||
)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, trade_no: str = ""):
|
||||
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).limit(1)
|
||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order or order.status != "pending":
|
||||
logger.info(f"Order {order_no} not found or already processed, skipping")
|
||||
|
||||
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"
|
||||
@@ -475,8 +837,143 @@ async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, t
|
||||
f"充值成功({order.credits}积分)",
|
||||
related_id=order.id,
|
||||
)
|
||||
await db.flush()
|
||||
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
|
||||
|
||||
# 扣除积分
|
||||
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)}"}
|
||||
|
||||
@@ -62,6 +62,9 @@ async def create_user_oauth_app(
|
||||
secret: str,
|
||||
open_type: int,
|
||||
create_by: str | None = None,
|
||||
count: int = 100,
|
||||
auth_url: str | None = None,
|
||||
company: str | None = None,
|
||||
) -> UserOAuthApp:
|
||||
existing = await get_user_oauth_app_by_app_id(db, app_id)
|
||||
if existing:
|
||||
@@ -72,6 +75,9 @@ async def create_user_oauth_app(
|
||||
app_id=app_id,
|
||||
secret=secret,
|
||||
open_type=open_type,
|
||||
count=count,
|
||||
auth_url=auth_url,
|
||||
company=company,
|
||||
create_by=create_by,
|
||||
)
|
||||
db.add(app)
|
||||
@@ -87,6 +93,9 @@ async def update_user_oauth_app(
|
||||
secret: str | None = None,
|
||||
open_type: int | None = None,
|
||||
status: int | None = None,
|
||||
count: int | None = None,
|
||||
auth_url: str | None = None,
|
||||
company: str | None = None,
|
||||
create_by: str | None = None,
|
||||
) -> UserOAuthApp | None:
|
||||
app = await get_user_oauth_app_by_id(db, id)
|
||||
@@ -99,6 +108,12 @@ async def update_user_oauth_app(
|
||||
app.open_type = open_type
|
||||
if status is not None:
|
||||
app.status = status
|
||||
if count is not None:
|
||||
app.count = count
|
||||
if auth_url is not None:
|
||||
app.auth_url = auth_url
|
||||
if company is not None:
|
||||
app.company = company
|
||||
if create_by is not None:
|
||||
app.create_by = create_by
|
||||
|
||||
|
||||
Reference in New Issue
Block a user