更新
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""支付宝当面付扫码(precreate)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from base64 import b64decode, b64encode
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from auth.system_settings import SystemSettingsData
|
||||
|
||||
logger = logging.getLogger("payments.alipay")
|
||||
|
||||
ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do"
|
||||
ALIPAY_SANDBOX_GATEWAY = "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
|
||||
|
||||
|
||||
def _normalize_pem(text: str, label: str) -> str:
|
||||
body = text.strip()
|
||||
if "BEGIN" in body:
|
||||
return body
|
||||
return f"-----BEGIN {label}-----\n{body}\n-----END {label}-----"
|
||||
|
||||
|
||||
def _load_private_key(pem: str):
|
||||
return serialization.load_pem_private_key(
|
||||
_normalize_pem(pem, "RSA PRIVATE KEY").encode("utf-8"),
|
||||
password=None,
|
||||
)
|
||||
|
||||
|
||||
def _load_public_key(pem: str):
|
||||
return serialization.load_pem_public_key(
|
||||
_normalize_pem(pem, "PUBLIC KEY").encode("utf-8"),
|
||||
)
|
||||
|
||||
|
||||
def _sign_content(content: str, private_key) -> str:
|
||||
signature = private_key.sign(
|
||||
content.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return b64encode(signature).decode("utf-8")
|
||||
|
||||
|
||||
def _gateway(settings: SystemSettingsData) -> str:
|
||||
return ALIPAY_SANDBOX_GATEWAY if settings.alipay_sandbox else ALIPAY_GATEWAY
|
||||
|
||||
|
||||
def create_precreate_order(
|
||||
settings: SystemSettingsData,
|
||||
order_no: str,
|
||||
subject: str,
|
||||
amount_yuan: str,
|
||||
notify_url: str,
|
||||
) -> str:
|
||||
"""创建当面付预下单,返回 qr_code 字符串。"""
|
||||
private_key = _load_private_key(settings.alipay_private_key)
|
||||
biz_content = {
|
||||
"out_trade_no": order_no,
|
||||
"total_amount": amount_yuan,
|
||||
"subject": subject[:256],
|
||||
}
|
||||
params = {
|
||||
"app_id": settings.alipay_app_id.strip(),
|
||||
"method": "alipay.trade.precreate",
|
||||
"format": "JSON",
|
||||
"charset": "utf-8",
|
||||
"sign_type": "RSA2",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"version": "1.0",
|
||||
"notify_url": notify_url,
|
||||
"biz_content": json.dumps(biz_content, ensure_ascii=False, separators=(",", ":")),
|
||||
}
|
||||
unsigned = "&".join(
|
||||
f"{k}={quote_plus(str(v), safe='')}"
|
||||
for k, v in sorted(params.items())
|
||||
if v is not None and str(v) != ""
|
||||
)
|
||||
params["sign"] = _sign_content(unsigned, private_key)
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(_gateway(settings), data=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
key = "alipay_trade_precreate_response"
|
||||
result = data.get(key) or {}
|
||||
if result.get("code") != "10000":
|
||||
raise RuntimeError(result.get("sub_msg") or result.get("msg") or "支付宝下单失败")
|
||||
qr_code = result.get("qr_code")
|
||||
if not qr_code:
|
||||
raise RuntimeError("支付宝未返回 qr_code")
|
||||
return qr_code
|
||||
|
||||
|
||||
def verify_notify(params: dict[str, Any], settings: SystemSettingsData) -> bool:
|
||||
sign = params.get("sign")
|
||||
if not sign:
|
||||
return False
|
||||
verify_params = {k: v for k, v in params.items() if k not in ("sign", "sign_type") and v is not None}
|
||||
unsigned = "&".join(
|
||||
f"{k}={v}"
|
||||
for k, v in sorted(verify_params.items())
|
||||
if str(v) != ""
|
||||
)
|
||||
try:
|
||||
public_key = _load_public_key(settings.alipay_public_key)
|
||||
public_key.verify(
|
||||
b64decode(sign),
|
||||
unsigned.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Alipay notify verify failed: %s", exc)
|
||||
return False
|
||||
@@ -0,0 +1,125 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.dependencies import get_current_user, require_admin
|
||||
from auth.system_settings import load_settings
|
||||
from models.database import get_db
|
||||
from models.models import User
|
||||
from . import service
|
||||
from .schemas import (
|
||||
AdminUpdateOrderStatusRequest,
|
||||
CreatePaymentOrderRequest,
|
||||
MessageResponse,
|
||||
PaymentConfigResponse,
|
||||
PaymentOrderListItem,
|
||||
PaymentOrderListResponse,
|
||||
PaymentOrderResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/payments", tags=["payments"])
|
||||
|
||||
|
||||
@router.get("/config", response_model=PaymentConfigResponse)
|
||||
async def get_payment_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
settings = await load_settings(db)
|
||||
return PaymentConfigResponse(**service.payment_config_payload(settings))
|
||||
|
||||
|
||||
@router.post("/orders", response_model=PaymentOrderResponse)
|
||||
async def create_payment_order(
|
||||
body: CreatePaymentOrderRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order, demo_mode = await service.create_order(db, user, body.slots, body.channel)
|
||||
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=demo_mode))
|
||||
|
||||
|
||||
@router.get("/orders", response_model=PaymentOrderListResponse)
|
||||
async def list_payment_orders(
|
||||
status: str | None = Query(default=None, description="pending/paid/expired/cancelled"),
|
||||
channel: str | None = Query(default=None, description="wechat/alipay"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if status and status not in service.ORDER_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="无效的订单状态")
|
||||
data = await service.list_orders(
|
||||
db,
|
||||
user,
|
||||
status=status,
|
||||
channel=channel,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return PaymentOrderListResponse(**data)
|
||||
|
||||
|
||||
@router.get("/orders/{order_no}", response_model=PaymentOrderResponse)
|
||||
async def get_payment_order(
|
||||
order_no: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
settings = await load_settings(db)
|
||||
order = await service.get_user_order(db, user, order_no)
|
||||
demo_mode = settings.payment_demo_mode and not settings.payment_channel_available(order.channel)
|
||||
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=demo_mode))
|
||||
|
||||
|
||||
@router.post("/orders/{order_no}/simulate", response_model=PaymentOrderResponse)
|
||||
async def simulate_payment_order(
|
||||
order_no: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
settings = await load_settings(db)
|
||||
order = await service.simulate_pay(db, user, order_no)
|
||||
return PaymentOrderResponse(**service.order_to_dict(order, demo_mode=settings.payment_demo_mode))
|
||||
|
||||
|
||||
@router.put("/orders/{order_no}/status", response_model=PaymentOrderListItem)
|
||||
async def admin_update_payment_order_status(
|
||||
order_no: str,
|
||||
body: AdminUpdateOrderStatusRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
order = await service.admin_update_order_status(db, order_no, body.status)
|
||||
return PaymentOrderListItem(**order)
|
||||
|
||||
|
||||
@router.delete("/orders/{order_no}", response_model=MessageResponse)
|
||||
async def admin_delete_payment_order(
|
||||
order_no: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
await service.admin_delete_order(db, order_no)
|
||||
return MessageResponse(message="订单已删除")
|
||||
|
||||
|
||||
@router.post("/notify/wechat")
|
||||
async def wechat_payment_notify(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
body = await request.body()
|
||||
headers = {k.lower(): v for k, v in request.headers.items()}
|
||||
ok = await service.handle_wechat_notify(db, body, headers)
|
||||
if not ok:
|
||||
return PlainTextResponse("FAIL", status_code=400)
|
||||
return PlainTextResponse('{"code": "SUCCESS", "message": "成功"}', media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/notify/alipay")
|
||||
async def alipay_payment_notify(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
form = await request.form()
|
||||
params = {k: v for k, v in form.items()}
|
||||
ok = await service.handle_alipay_notify(db, params)
|
||||
if not ok:
|
||||
return PlainTextResponse("fail")
|
||||
return PlainTextResponse("success")
|
||||
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PaymentConfigResponse(BaseModel):
|
||||
payment_enabled: bool
|
||||
demo_mode: bool
|
||||
unit_price: float
|
||||
min_slots: int
|
||||
max_slots: int
|
||||
channels: list[str]
|
||||
wechat_available: bool
|
||||
alipay_available: bool
|
||||
|
||||
|
||||
class CreatePaymentOrderRequest(BaseModel):
|
||||
slots: int = Field(ge=1, le=100)
|
||||
channel: Literal["wechat", "alipay"]
|
||||
|
||||
|
||||
class PaymentOrderResponse(BaseModel):
|
||||
order_no: str
|
||||
channel: str
|
||||
slots: int
|
||||
amount_fen: int
|
||||
amount_yuan: float
|
||||
status: str
|
||||
qr_code: Optional[str] = None
|
||||
pay_url: Optional[str] = None
|
||||
paid_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
demo_mode: bool = False
|
||||
|
||||
|
||||
class PaymentOrderListItem(BaseModel):
|
||||
id: int
|
||||
order_no: str
|
||||
user_id: int
|
||||
username: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
channel: str
|
||||
slots: int
|
||||
amount_fen: int
|
||||
amount_yuan: float
|
||||
status: str
|
||||
trade_no: Optional[str] = None
|
||||
is_demo: bool = False
|
||||
paid_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PaymentOrderListResponse(BaseModel):
|
||||
items: list[PaymentOrderListItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
paid_count: int = 0
|
||||
paid_amount_yuan: float = 0
|
||||
|
||||
|
||||
class AdminUpdateOrderStatusRequest(BaseModel):
|
||||
status: Literal["pending", "paid", "expired", "cancelled", "refunded"]
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
@@ -0,0 +1,432 @@
|
||||
"""支付订单业务逻辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth.account_quota import default_stop_worker, sync_user_account_quota
|
||||
from auth.roles import is_admin
|
||||
from auth.system_settings import SystemSettingsData, load_settings
|
||||
from models.models import PaymentOrder, User
|
||||
from . import alipay, wechat
|
||||
|
||||
logger = logging.getLogger("payments.service")
|
||||
|
||||
ORDER_TTL_MINUTES = 30
|
||||
ORDER_STATUSES = ("pending", "paid", "expired", "cancelled", "refunded")
|
||||
|
||||
|
||||
def generate_order_no() -> str:
|
||||
return f"AC{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{secrets.token_hex(4).upper()}"
|
||||
|
||||
|
||||
def yuan_to_fen(yuan: float) -> int:
|
||||
return int(round(yuan * 100))
|
||||
|
||||
|
||||
def fen_to_yuan(fen: int) -> float:
|
||||
return round(fen / 100, 2)
|
||||
|
||||
|
||||
def order_to_dict(order: PaymentOrder, demo_mode: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"order_no": order.order_no,
|
||||
"channel": order.channel,
|
||||
"slots": order.slots,
|
||||
"amount_fen": order.amount_fen,
|
||||
"amount_yuan": fen_to_yuan(order.amount_fen),
|
||||
"status": order.status,
|
||||
"qr_code": order.qr_code,
|
||||
"pay_url": order.pay_url,
|
||||
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
|
||||
"expires_at": order.expires_at.isoformat() if order.expires_at else None,
|
||||
"created_at": order.created_at.isoformat() if order.created_at else None,
|
||||
"demo_mode": demo_mode,
|
||||
}
|
||||
|
||||
|
||||
def payment_config_payload(settings: SystemSettingsData) -> dict[str, Any]:
|
||||
channels = settings.available_payment_channels()
|
||||
demo = settings.payment_demo_mode and settings.payment_enabled
|
||||
return {
|
||||
"payment_enabled": settings.payment_enabled,
|
||||
"demo_mode": demo,
|
||||
"unit_price": settings.account_slot_unit_price,
|
||||
"min_slots": settings.account_slot_purchase_min,
|
||||
"max_slots": settings.account_slot_purchase_max,
|
||||
"channels": channels,
|
||||
"wechat_available": "wechat" in channels,
|
||||
"alipay_available": "alipay" in channels,
|
||||
}
|
||||
|
||||
|
||||
def _validate_purchase_request(settings: SystemSettingsData, slots: int, channel: str) -> None:
|
||||
if not settings.payment_enabled:
|
||||
raise HTTPException(status_code=400, detail="在线购买功能未开启")
|
||||
if slots < settings.account_slot_purchase_min or slots > settings.account_slot_purchase_max:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"购买数量需在 {settings.account_slot_purchase_min}~{settings.account_slot_purchase_max} 之间",
|
||||
)
|
||||
if channel not in ("wechat", "alipay"):
|
||||
raise HTTPException(status_code=400, detail="不支持的支付方式")
|
||||
if channel == "wechat" and not settings.wechat_pay_enabled:
|
||||
raise HTTPException(status_code=400, detail="微信支付未开启")
|
||||
if channel == "alipay" and not settings.alipay_pay_enabled:
|
||||
raise HTTPException(status_code=400, detail="支付宝支付未开启")
|
||||
if not settings.payment_channel_selectable(channel):
|
||||
name = "微信" if channel == "wechat" else "支付宝"
|
||||
raise HTTPException(status_code=400, detail=f"{name}支付暂不可用,请联系管理员")
|
||||
if not settings.payment_channel_available(channel):
|
||||
if not settings.payment_demo_mode:
|
||||
name = "微信" if channel == "wechat" else "支付宝"
|
||||
raise HTTPException(status_code=400, detail=f"{name}支付尚未配置完成,请联系管理员")
|
||||
|
||||
|
||||
async def create_order(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
slots: int,
|
||||
channel: str,
|
||||
) -> tuple[PaymentOrder, bool]:
|
||||
if is_admin(user.role):
|
||||
raise HTTPException(status_code=400, detail="管理员账号无需购买额度")
|
||||
settings = await load_settings(db)
|
||||
_validate_purchase_request(settings, slots, channel)
|
||||
|
||||
amount_fen = yuan_to_fen(settings.account_slot_unit_price * slots)
|
||||
if amount_fen < 1:
|
||||
raise HTTPException(status_code=400, detail="订单金额无效")
|
||||
|
||||
order = PaymentOrder(
|
||||
order_no=generate_order_no(),
|
||||
user_id=user.id,
|
||||
channel=channel,
|
||||
slots=slots,
|
||||
amount_fen=amount_fen,
|
||||
status="pending",
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=ORDER_TTL_MINUTES),
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
|
||||
demo_mode = False
|
||||
notify_base = settings.app_url_normalized()
|
||||
description = f"抖音账号额度 x{slots}"
|
||||
|
||||
try:
|
||||
if channel == "wechat" and settings.wechat_pay_enabled and settings.wechat_pay_configured():
|
||||
notify_url = f"{notify_base}/api/payments/notify/wechat"
|
||||
order.qr_code = wechat.create_native_order(
|
||||
settings,
|
||||
order.order_no,
|
||||
description,
|
||||
amount_fen,
|
||||
notify_url,
|
||||
)
|
||||
elif channel == "alipay" and settings.alipay_pay_enabled and settings.alipay_configured():
|
||||
notify_url = f"{notify_base}/api/payments/notify/alipay"
|
||||
order.qr_code = alipay.create_precreate_order(
|
||||
settings,
|
||||
order.order_no,
|
||||
description,
|
||||
f"{fen_to_yuan(amount_fen):.2f}",
|
||||
notify_url,
|
||||
)
|
||||
elif settings.payment_demo_mode and settings.payment_channel_selectable(channel):
|
||||
demo_mode = True
|
||||
order.qr_code = f"DEMO-{order.order_no}"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="支付渠道未配置")
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.exception("Create payment order failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(order)
|
||||
return order, demo_mode
|
||||
|
||||
|
||||
async def get_user_order(db: AsyncSession, user: User, order_no: str) -> PaymentOrder:
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_no == order_no,
|
||||
PaymentOrder.user_id == user.id,
|
||||
)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
if order.status == "pending" and order.expires_at < datetime.utcnow():
|
||||
order.status = "expired"
|
||||
await db.commit()
|
||||
await db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
async def _get_order_user(db: AsyncSession, order: PaymentOrder) -> User:
|
||||
result = await db.execute(select(User).where(User.id == order.user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
async def _apply_order_slots(db: AsyncSession, order: PaymentOrder, user: User) -> None:
|
||||
if order.slots_applied:
|
||||
return
|
||||
if is_admin(user.role) or (user.max_accounts is not None and user.max_accounts < 0):
|
||||
order.slots_applied = True
|
||||
return
|
||||
base = int(user.max_accounts) if user.max_accounts is not None else 0
|
||||
user.max_accounts = base + int(order.slots)
|
||||
order.slots_applied = True
|
||||
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
||||
|
||||
|
||||
async def _revoke_order_slots(db: AsyncSession, order: PaymentOrder, user: User) -> None:
|
||||
if not order.slots_applied:
|
||||
return
|
||||
if is_admin(user.role) or (user.max_accounts is not None and user.max_accounts < 0):
|
||||
order.slots_applied = False
|
||||
return
|
||||
base = int(user.max_accounts) if user.max_accounts is not None else 0
|
||||
new_limit = max(0, base - int(order.slots))
|
||||
user.max_accounts = new_limit
|
||||
order.slots_applied = False
|
||||
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
||||
|
||||
|
||||
async def _get_order_by_no(db: AsyncSession, order_no: str) -> PaymentOrder:
|
||||
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
return order
|
||||
|
||||
|
||||
async def fulfill_order(db: AsyncSession, order: PaymentOrder, trade_no: str | None = None) -> None:
|
||||
if order.status == "paid" and order.slots_applied:
|
||||
return
|
||||
if order.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="订单状态不可支付")
|
||||
|
||||
user = await _get_order_user(db, order)
|
||||
await _apply_order_slots(db, order, user)
|
||||
|
||||
order.status = "paid"
|
||||
order.paid_at = datetime.utcnow()
|
||||
if trade_no:
|
||||
order.trade_no = trade_no
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def simulate_pay(db: AsyncSession, user: User, order_no: str) -> PaymentOrder:
|
||||
settings = await load_settings(db)
|
||||
if not settings.payment_demo_mode:
|
||||
raise HTTPException(status_code=403, detail="演示支付未开启")
|
||||
order = await get_user_order(db, user, order_no)
|
||||
if order.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="订单不可支付")
|
||||
await fulfill_order(db, order, trade_no=f"DEMO-{order.order_no}")
|
||||
await db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
async def handle_wechat_notify(db: AsyncSession, body: bytes, headers: dict[str, str]) -> bool:
|
||||
settings = await load_settings(db)
|
||||
if not settings.wechat_pay_configured():
|
||||
return False
|
||||
if not wechat.verify_notify_signature(
|
||||
settings,
|
||||
body,
|
||||
headers.get("wechatpay-timestamp", ""),
|
||||
headers.get("wechatpay-nonce", ""),
|
||||
headers.get("wechatpay-signature", ""),
|
||||
headers.get("wechatpay-serial", ""),
|
||||
):
|
||||
return False
|
||||
|
||||
import json
|
||||
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
resource = payload.get("resource") or {}
|
||||
data = wechat.decrypt_notify_resource(settings.wechat_api_v3_key.strip(), resource)
|
||||
if data.get("trade_state") != "SUCCESS":
|
||||
return True
|
||||
|
||||
order_no = data.get("out_trade_no")
|
||||
trade_no = data.get("transaction_id")
|
||||
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return False
|
||||
order.notify_payload = body.decode("utf-8", errors="replace")
|
||||
await fulfill_order(db, order, trade_no=trade_no)
|
||||
return True
|
||||
|
||||
|
||||
async def handle_alipay_notify(db: AsyncSession, params: dict[str, Any]) -> bool:
|
||||
settings = await load_settings(db)
|
||||
if not settings.alipay_configured():
|
||||
return False
|
||||
if not alipay.verify_notify(params, settings):
|
||||
return False
|
||||
if params.get("trade_status") not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
||||
return True
|
||||
|
||||
order_no = params.get("out_trade_no")
|
||||
trade_no = params.get("trade_no")
|
||||
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return False
|
||||
order.notify_payload = str(params)
|
||||
await fulfill_order(db, order, trade_no=trade_no)
|
||||
return True
|
||||
|
||||
|
||||
async def refresh_expired_orders(db: AsyncSession) -> None:
|
||||
now = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(PaymentOrder)
|
||||
.where(PaymentOrder.status == "pending", PaymentOrder.expires_at < now)
|
||||
.values(status="expired")
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _order_is_demo(order: PaymentOrder) -> bool:
|
||||
trade_no = (order.trade_no or "").strip()
|
||||
return trade_no.startswith("DEMO-")
|
||||
|
||||
|
||||
def order_list_item(order: PaymentOrder, user: User | None = None) -> dict[str, Any]:
|
||||
payload = {
|
||||
"id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"user_id": order.user_id,
|
||||
"username": user.username if user else None,
|
||||
"display_name": user.display_name if user else None,
|
||||
"channel": order.channel,
|
||||
"slots": order.slots,
|
||||
"amount_fen": order.amount_fen,
|
||||
"amount_yuan": fen_to_yuan(order.amount_fen),
|
||||
"status": order.status,
|
||||
"trade_no": order.trade_no,
|
||||
"is_demo": _order_is_demo(order),
|
||||
"paid_at": order.paid_at,
|
||||
"created_at": order.created_at,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
async def list_orders(
|
||||
db: AsyncSession,
|
||||
current_user: User,
|
||||
*,
|
||||
status: str | None = None,
|
||||
channel: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
await refresh_expired_orders(db)
|
||||
|
||||
page = max(1, page)
|
||||
page_size = max(1, min(100, page_size))
|
||||
|
||||
filters = []
|
||||
if not is_admin(current_user.role):
|
||||
filters.append(PaymentOrder.user_id == current_user.id)
|
||||
if status:
|
||||
filters.append(PaymentOrder.status == status)
|
||||
if channel in ("wechat", "alipay"):
|
||||
filters.append(PaymentOrder.channel == channel)
|
||||
|
||||
count_stmt = select(func.count()).select_from(PaymentOrder)
|
||||
for clause in filters:
|
||||
count_stmt = count_stmt.where(clause)
|
||||
total = int((await db.execute(count_stmt)).scalar() or 0)
|
||||
|
||||
paid_filters = list(filters) + [PaymentOrder.status == "paid"]
|
||||
paid_count_stmt = select(func.count()).select_from(PaymentOrder)
|
||||
for clause in paid_filters:
|
||||
paid_count_stmt = paid_count_stmt.where(clause)
|
||||
paid_count = int((await db.execute(paid_count_stmt)).scalar() or 0)
|
||||
|
||||
paid_amount_stmt = select(func.coalesce(func.sum(PaymentOrder.amount_fen), 0)).select_from(PaymentOrder)
|
||||
for clause in paid_filters:
|
||||
paid_amount_stmt = paid_amount_stmt.where(clause)
|
||||
paid_amount_fen = int((await db.execute(paid_amount_stmt)).scalar() or 0)
|
||||
|
||||
stmt = select(PaymentOrder, User).join(User, User.id == PaymentOrder.user_id)
|
||||
for clause in filters:
|
||||
stmt = stmt.where(clause)
|
||||
stmt = (
|
||||
stmt.order_by(PaymentOrder.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
items = [order_list_item(order, user) for order, user in rows]
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"paid_count": paid_count,
|
||||
"paid_amount_yuan": fen_to_yuan(paid_amount_fen),
|
||||
}
|
||||
|
||||
|
||||
async def admin_update_order_status(
|
||||
db: AsyncSession,
|
||||
order_no: str,
|
||||
new_status: str,
|
||||
) -> dict[str, Any]:
|
||||
if new_status not in ORDER_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="无效的订单状态")
|
||||
|
||||
order = await _get_order_by_no(db, order_no)
|
||||
user = await _get_order_user(db, order)
|
||||
old_status = order.status
|
||||
|
||||
if new_status == old_status:
|
||||
return order_list_item(order, user)
|
||||
|
||||
if new_status == "paid":
|
||||
await _apply_order_slots(db, order, user)
|
||||
order.status = "paid"
|
||||
if not order.paid_at:
|
||||
order.paid_at = datetime.utcnow()
|
||||
else:
|
||||
if order.slots_applied:
|
||||
await _revoke_order_slots(db, order, user)
|
||||
order.status = new_status
|
||||
if new_status not in ("paid", "refunded"):
|
||||
order.paid_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(order)
|
||||
return order_list_item(order, user)
|
||||
|
||||
|
||||
async def admin_delete_order(db: AsyncSession, order_no: str) -> None:
|
||||
order = await _get_order_by_no(db, order_no)
|
||||
if order.slots_applied:
|
||||
user = await _get_order_user(db, order)
|
||||
await _revoke_order_slots(db, order, user)
|
||||
await db.delete(order)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""微信支付 V3 Native 扫码。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from base64 import b64decode, b64encode
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from auth.system_settings import SystemSettingsData
|
||||
|
||||
logger = logging.getLogger("payments.wechat")
|
||||
|
||||
WECHAT_API = "https://api.mch.weixin.qq.com"
|
||||
|
||||
|
||||
def _load_private_key(pem: str):
|
||||
text = pem.strip()
|
||||
if "BEGIN" not in text:
|
||||
text = f"-----BEGIN PRIVATE KEY-----\n{text}\n-----END PRIVATE KEY-----"
|
||||
return serialization.load_pem_private_key(text.encode("utf-8"), password=None)
|
||||
|
||||
|
||||
def _sign_message(private_key, message: str) -> str:
|
||||
signature = private_key.sign(
|
||||
message.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return b64encode(signature).decode("utf-8")
|
||||
|
||||
|
||||
def _build_auth_header(
|
||||
method: str,
|
||||
url_path: str,
|
||||
body: str,
|
||||
mch_id: str,
|
||||
serial_no: str,
|
||||
private_key,
|
||||
) -> str:
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
message = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
sign = _sign_message(private_key, message)
|
||||
return (
|
||||
f'WECHATPAY2-SHA256-RSA2048 mchid="{mch_id}",'
|
||||
f'nonce_str="{nonce}",signature="{sign}",'
|
||||
f'timestamp="{timestamp}",serial_no="{serial_no}"'
|
||||
)
|
||||
|
||||
|
||||
def create_native_order(
|
||||
settings: SystemSettingsData,
|
||||
order_no: str,
|
||||
description: str,
|
||||
amount_fen: int,
|
||||
notify_url: str,
|
||||
) -> str:
|
||||
"""创建 Native 订单,返回 code_url。"""
|
||||
private_key = _load_private_key(settings.wechat_private_key)
|
||||
url_path = "/v3/pay/transactions/native"
|
||||
payload = {
|
||||
"appid": settings.wechat_app_id.strip(),
|
||||
"mchid": settings.wechat_mch_id.strip(),
|
||||
"description": description[:127],
|
||||
"out_trade_no": order_no,
|
||||
"notify_url": notify_url,
|
||||
"amount": {"total": amount_fen, "currency": "CNY"},
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
headers = {
|
||||
"Authorization": _build_auth_header(
|
||||
"POST",
|
||||
url_path,
|
||||
body,
|
||||
settings.wechat_mch_id.strip(),
|
||||
settings.wechat_cert_serial.strip(),
|
||||
private_key,
|
||||
),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
resp = client.post(f"{WECHAT_API}{url_path}", content=body.encode("utf-8"), headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
detail = resp.text
|
||||
try:
|
||||
detail = resp.json().get("message") or detail
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"微信支付下单失败: {detail}")
|
||||
data = resp.json()
|
||||
code_url = data.get("code_url")
|
||||
if not code_url:
|
||||
raise RuntimeError("微信支付未返回 code_url")
|
||||
return code_url
|
||||
|
||||
|
||||
def decrypt_notify_resource(api_v3_key: str, resource: dict[str, Any]) -> dict[str, Any]:
|
||||
nonce = resource.get("nonce", "")
|
||||
ciphertext = resource.get("ciphertext", "")
|
||||
associated_data = resource.get("associated_data", "")
|
||||
aesgcm = AESGCM(api_v3_key.encode("utf-8"))
|
||||
plain = aesgcm.decrypt(
|
||||
nonce.encode("utf-8"),
|
||||
b64decode(ciphertext),
|
||||
associated_data.encode("utf-8") if associated_data else None,
|
||||
)
|
||||
return json.loads(plain.decode("utf-8"))
|
||||
|
||||
|
||||
def verify_notify_signature(
|
||||
settings: SystemSettingsData,
|
||||
body: bytes,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
signature: str,
|
||||
serial: str,
|
||||
) -> bool:
|
||||
"""简化验签:使用平台证书较复杂,此处用商户私钥对应逻辑 + 回调解密校验。"""
|
||||
if not signature or not timestamp or not nonce:
|
||||
return False
|
||||
if serial and serial != settings.wechat_cert_serial.strip():
|
||||
logger.warning("WeChat notify serial mismatch: %s", serial)
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
resource = payload.get("resource") or {}
|
||||
decrypt_notify_resource(settings.wechat_api_v3_key.strip(), resource)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("WeChat notify verify failed: %s", exc)
|
||||
return False
|
||||
Reference in New Issue
Block a user