Files
video-gen/video-gen-api/app/api/v1/payments.py
T
2026-06-15 10:41:09 +08:00

371 lines
15 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
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("payment")
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
from app.models.recharge_package import RechargePackage
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import (
create_recharge_order,
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
process_refund,
_get_payment_configs,
_close_alipay_order,
_get_order_expire_seconds,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@router.get("/methods")
async def get_payment_methods(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Return which payment methods are enabled (from admin config)."""
from app.services.payment import _get_payment_configs
configs = await _get_payment_configs(db)
return {
"alipay": configs.get("payment_alipay_enabled", "").lower() == "true",
"wechat": configs.get("payment_wechat_enabled", "").lower() == "true",
}
@router.post("/recharge", response_model=PaymentOrderOut)
async def recharge(
req: RechargeRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if req.method not in ("wechat", "alipay"):
raise HTTPException(status_code=400, detail="不支持的支付方式")
# Check if the selected payment method is enabled in admin config
from app.services.payment import _get_payment_configs, _is_mock_mode
configs = await _get_payment_configs(db)
if not _is_mock_mode(configs):
enabled_key = f"payment_{req.method}_enabled"
if configs.get(enabled_key, "").lower() != "true":
raise HTTPException(status_code=400, detail="该支付方式未启用")
result = await db.execute(
select(RechargePackage).where(
RechargePackage.id == req.plan,
RechargePackage.is_active == True,
)
.limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=400, detail="无效的套餐")
try:
order = await create_recharge_order(
db,
current_user.id,
credits=pkg.credits,
price=pkg.price,
label=pkg.name,
bonus_credits=pkg.bonus_credits,
method=req.method,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return order
@router.post("/wechat/callback")
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
# 读取微信支付回调数据
body_bytes = await request.body()
body_str = body_bytes.decode("utf-8")
# 获取配置
from app.services.payment import _get_payment_configs, _is_mock_mode, _get_wechat_client
db_configs = await _get_payment_configs(db)
# 检查 mock 模式
if _is_mock_mode(db_configs):
try:
import json
data = json.loads(body_str) if body_str else {}
order_no = data.get("out_trade_no")
if order_no:
await process_payment_success_by_order_no(db, order_no)
logger.info(f"Mock WeChat callback processed: order_no={order_no}")
return {"code": "SUCCESS", "message": "OK"}
except Exception as e:
logger.exception(f"Mock WeChat callback error: {e}")
return {"code": "SUCCESS", "message": "OK"} # 微信要求即使处理失败也返回成功
# 真实模式:使用 wechatpayv3 SDK 工具验证回调并解析数据
try:
from wechatpayv3.utils import (
rsa_verify, load_public_key, sha256, b64decode,
AESGCM, InvalidTag
)
mch_id = db_configs.get("payment_wechat_mch_id", "")
api_v3_key = db_configs.get("payment_wechat_api_v3_key", "")
public_key = db_configs.get("payment_wechat_public_key", "")
if not all([mch_id, api_v3_key]):
logger.error("WeChat payment config missing for callback")
return {"code": "SUCCESS", "message": "OK"}
# 从请求头获取必要信息(不区分大小写)
headers = {k.lower(): v for k, v in dict(request.headers).items()}
timestamp = headers.get("wechatpay-timestamp", "")
nonce = headers.get("wechatpay-nonce", "")
signature = headers.get("wechatpay-signature", "")
serial_no = headers.get("wechatpay-serial", "")
# 验证签名:使用平台公钥验证
if public_key and serial_no:
try:
# 构造签名串:timestamp + "\n" + nonce + "\n" + body + "\n"
# 符合微信支付官方文档规范:https://pay.weixin.qq.com/doc/v3/merchant/4013053249
is_verified = rsa_verify(
timestamp=timestamp,
nonce=nonce,
body=body_str,
signature=signature,
public_key=load_public_key(public_key)
)
if not is_verified:
logger.warning(f"WeChat callback signature verification failed: serial={serial_no}")
raise HTTPException(status_code=400, detail="签名验证失败")
except Exception as e:
logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
raise HTTPException(status_code=400, detail="签名验证失败")
else:
if not public_key:
logger.warning("WeChat platform public key not configured, skipping signature verification")
if not serial_no:
logger.warning("Wechatpay-Serial header missing, skipping signature verification")
# 解密回调数据:使用 API v3 key
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
import json
body_data = json.loads(body_str) if body_str else {}
resource = body_data.get("resource", {})
if not resource:
logger.error("WeChat callback resource not found")
raise HTTPException(status_code=400, detail="数据格式错误")
# 验证加密算法(官方文档要求固定为 AEAD_AES_256_GCM
algorithm = resource.get("algorithm", "")
if algorithm != "AEAD_AES_256_GCM":
logger.error(f"WeChat callback unsupported algorithm: {algorithm}")
raise HTTPException(status_code=400, detail="不支持的加密算法")
ciphertext = resource.get("ciphertext", "")
associated_data = resource.get("associated_data", "")
nonce_str = resource.get("nonce", "")
# 参数验证
if not ciphertext:
logger.error("WeChat callback ciphertext is empty")
raise HTTPException(status_code=400, detail="密文为空")
if not nonce_str:
logger.error("WeChat callback nonce is empty")
raise HTTPException(status_code=400, detail="随机数为空")
# 使用 AES-GCM 解密(符合官方文档规范)
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
try:
# API v3 key 需要转换为字节串
api_v3_key_bytes = api_v3_key.encode('utf-8')
# ciphertext 是 Base64 编码的,需要解码
ciphertext_bytes = b64decode(ciphertext)
# nonce 直接使用字符串编码(官方文档方式)
nonce_bytes = nonce_str.encode('utf-8')
# associated_data 是字符串,直接编码
associated_data_bytes = associated_data.encode('utf-8') if associated_data else b''
aesgcm = AESGCM(api_v3_key_bytes)
decrypted_str = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes)
except InvalidTag:
logger.error("WeChat callback decryption failed: Invalid tag (key or data mismatch)")
raise HTTPException(status_code=400, detail="数据解密失败(密钥或数据不匹配)")
except Exception as e:
logger.error(f"WeChat callback decryption failed: {e}")
raise HTTPException(status_code=400, detail="数据解密失败")
if not decrypted_str:
logger.error("WeChat callback decryption returned empty")
raise HTTPException(status_code=400, detail="数据解密失败")
decrypted_data = json.loads(decrypted_str)
event_type = body_data.get("event_type", "")
# 处理支付成功回调
if event_type == "TRANSACTION.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
transaction_id = decrypted_data.get("transaction_id", "")
amount_info = decrypted_data.get("amount", {})
total_amount = amount_info.get("total", 0) / 100 # 转换为元
if order_no:
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount)
logger.info(
f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
# 处理退款回调
elif event_type == "REFUND.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
refund_id = decrypted_data.get("refund_id", "")
refund_status = decrypted_data.get("status", "")
if order_no and refund_status == "SUCCESS":
# 更新订单状态为已退款
from app.models import PaymentOrder
from sqlalchemy import select
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
order = result.scalar_one_or_none()
if order and order.status == "refunding":
order.status = "refunded"
order.transaction_id = refund_id
await db.commit()
logger.info(
f"WeChat refund callback processed: order_no={order_no}, "
f"refund_id={refund_id}, status={refund_status}"
)
return {"code": "SUCCESS", "message": "OK"}
except Exception as e:
logger.exception(f"WeChat callback processing error: {e}")
# 微信支付要求即使处理失败也返回成功,避免重复回调
return {"code": "SUCCESS", "message": "OK"}
@router.post("/alipay/callback")
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
form_data = await request.form()
data = dict(form_data)
logger.info(
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
f"data={data}"
)
# Verify signature first
if not await verify_alipay_callback(data, db):
raise HTTPException(status_code=400, detail="签名验证失败")
# Check trade_status only "TRADE_SUCCESS" and "TRADE_FINISHED" mean paid
trade_status = data.get("trade_status", "")
if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
logger.info(f"Alipay callback trade_status={trade_status}, ignoring")
return "success"
order_no = data.get("out_trade_no")
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
if order_no:
await process_payment_success_by_order_no(db, order_no, trade_no, total_amount)
return "success"
@router.get("/orders", response_model=list[PaymentOrderOut])
async def list_orders(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Auto-expire stale pending orders before returning
from app.services.payment import _check_and_expire_order
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id)
.order_by(PaymentOrder.created_at.desc())
)
orders = result.scalars().all()
for o in orders:
await _check_and_expire_order(db, o)
return orders
@router.get("/orders/{order_no}", response_model=PaymentOrderOut)
async def get_order(
order_no: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.services.payment import _check_and_expire_order
result = await db.execute(
select(PaymentOrder)
.where(
PaymentOrder.order_no == order_no,
PaymentOrder.user_id == current_user.id,
)
.limit(1)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
# Auto-expire if needed
await _check_and_expire_order(db, order)
return order
@router.post("/orders/{order_no}/cancel")
async def cancel_order(
order_no: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Cancel a pending order. Only the order owner can cancel, only if still pending."""
result = await db.execute(
select(PaymentOrder).where(
PaymentOrder.order_no == order_no,
PaymentOrder.user_id == current_user.id,
).limit(1)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
# If it's an Alipay or WeChat order, call close API first
db_configs = await _get_payment_configs(db)
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_no}: {e}")
elif order.payment_method == "wechat":
try:
from app.services.payment import _close_wechat_order
await _close_wechat_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
order.status = "cancelled"
await db.flush()
logger.info(
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
)
return {"ok": True}