This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+123
View File
@@ -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
+125
View File
@@ -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")
+69
View File
@@ -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
+432
View File
@@ -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()
+139
View File
@@ -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