400 lines
16 KiB
Python
400 lines
16 KiB
Python
import logging
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||
from sqlalchemy import select, func
|
||
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", "")
|
||
|
||
# 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝
|
||
if not public_key:
|
||
logger.error("WeChat platform public key not configured, cannot verify callback signature")
|
||
return {"code": "FAIL", "message": "Platform public key not configured"}
|
||
if not serial_no:
|
||
logger.error("Wechatpay-Serial header missing in callback")
|
||
return {"code": "FAIL", "message": "Missing Wechatpay-Serial header"}
|
||
if not timestamp or not nonce or not signature:
|
||
logger.error("WeChat callback missing required signature headers")
|
||
return {"code": "FAIL", "message": "Missing signature headers"}
|
||
|
||
# 验证签名:使用平台公钥验证
|
||
try:
|
||
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}")
|
||
return {"code": "FAIL", "message": "Signature verification failed"}
|
||
except Exception as e:
|
||
logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
|
||
return {"code": "FAIL", "message": "Signature verification error"}
|
||
|
||
# 解密回调数据:使用 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")
|
||
async def list_orders(
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
status_filter: str | None = Query(None, description="按状态筛选: pending/paid/refunded/failed/cancelled"),
|
||
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
|
||
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
|
||
invoice_mode: bool = Query(False, description="开票模式:仅返回已支付订单"),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
from app.services.payment import _check_and_expire_order
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
# 构建筛选条件
|
||
conditions = [PaymentOrder.user_id == current_user.id]
|
||
if status_filter:
|
||
conditions.append(PaymentOrder.status == status_filter)
|
||
if invoice_mode:
|
||
conditions.append(PaymentOrder.status == "paid")
|
||
if start_date:
|
||
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||
conditions.append(PaymentOrder.created_at >= start_dt)
|
||
if end_date:
|
||
end_dt = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=timezone.utc)
|
||
conditions.append(PaymentOrder.created_at < end_dt)
|
||
|
||
# 统计总数
|
||
count_query = select(func.count(PaymentOrder.id)).where(*conditions)
|
||
total = (await db.execute(count_query)).scalar() or 0
|
||
|
||
result = await db.execute(
|
||
select(PaymentOrder)
|
||
.where(*conditions)
|
||
.order_by(PaymentOrder.created_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
)
|
||
orders = result.scalars().all()
|
||
for o in orders:
|
||
await _check_and_expire_order(db, o)
|
||
|
||
return {"items": [PaymentOrderOut.model_validate(o) for o in orders], "total": total}
|
||
|
||
|
||
@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}
|