增加支付宝支付相关页面和程序
This commit is contained in:
@@ -22,3 +22,4 @@ video-gen-api/dist/
|
|||||||
# 忽略特定类型文件但保留目录
|
# 忽略特定类型文件但保留目录
|
||||||
# *.pyc
|
# *.pyc
|
||||||
# !dir/*.pycnode_modules/
|
# !dir/*.pycnode_modules/
|
||||||
|
*.tmp.*
|
||||||
@@ -21,6 +21,7 @@ video_item/
|
|||||||
| PostgreSQL | >= 14 | 推荐 16 |
|
| PostgreSQL | >= 14 | 推荐 16 |
|
||||||
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
||||||
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
||||||
|
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,6 +47,12 @@ pip install -e ".[pg,redis]"
|
|||||||
|
|
||||||
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
||||||
pip install -e ".[pg,redis,celery]"
|
pip install -e ".[pg,redis,celery]"
|
||||||
|
|
||||||
|
#安装阿里支付sdk
|
||||||
|
pip install -e ".[pg,redis,celery,alipay]"
|
||||||
|
|
||||||
|
#安装火山sdk
|
||||||
|
pip install -e ".[pg,redis,celery,alipay,volc]"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 配置环境变量
|
### 2. 配置环境变量
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger("videogen")
|
||||||
|
|
||||||
from app.dependencies import get_db, get_current_user
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.payment_order import PaymentOrder
|
from app.models.payment_order import PaymentOrder
|
||||||
from app.models.recharge_package import RechargePackage
|
from app.models.recharge_package import RechargePackage
|
||||||
from app.schemas.payment import RechargeRequest, PaymentOrderOut
|
from app.schemas.payment import RechargeRequest, PaymentOrderOut
|
||||||
from app.services.payment import create_recharge_order, verify_wechat_callback, verify_alipay_callback, process_payment_success
|
from app.services.payment import (
|
||||||
|
create_recharge_order,
|
||||||
|
verify_wechat_callback,
|
||||||
|
verify_alipay_callback,
|
||||||
|
process_payment_success_by_order_no,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||||
|
|
||||||
@@ -18,6 +27,9 @@ async def recharge(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
if req.method not in ("wechat", "alipay"):
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的支付方式")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RechargePackage).where(
|
select(RechargePackage).where(
|
||||||
RechargePackage.id == req.plan,
|
RechargePackage.id == req.plan,
|
||||||
@@ -35,6 +47,7 @@ async def recharge(
|
|||||||
price=pkg.price,
|
price=pkg.price,
|
||||||
label=pkg.name,
|
label=pkg.name,
|
||||||
bonus_credits=pkg.bonus_credits,
|
bonus_credits=pkg.bonus_credits,
|
||||||
|
method=req.method,
|
||||||
)
|
)
|
||||||
return order
|
return order
|
||||||
|
|
||||||
@@ -42,30 +55,35 @@ async def recharge(
|
|||||||
@router.post("/wechat/callback")
|
@router.post("/wechat/callback")
|
||||||
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
if not await verify_wechat_callback(data):
|
if not await verify_wechat_callback(data, db):
|
||||||
raise HTTPException(status_code=400, detail="签名验证失败")
|
raise HTTPException(status_code=400, detail="签名验证失败")
|
||||||
order_no = data.get("out_trade_no")
|
order_no = data.get("out_trade_no")
|
||||||
result = await db.execute(
|
if order_no:
|
||||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
|
await process_payment_success_by_order_no(db, order_no)
|
||||||
)
|
|
||||||
order = result.scalar_one_or_none()
|
|
||||||
if order:
|
|
||||||
await process_payment_success(db, order.id)
|
|
||||||
return {"code": "SUCCESS", "message": "OK"}
|
return {"code": "SUCCESS", "message": "OK"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alipay/callback")
|
@router.post("/alipay/callback")
|
||||||
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
data = await request.form()
|
form_data = await request.form()
|
||||||
if not await verify_alipay_callback(dict(data)):
|
data = dict(form_data)
|
||||||
|
logger.info(f"Alipay callback received: {list(data.keys())}")
|
||||||
|
|
||||||
|
# Verify signature first
|
||||||
|
if not await verify_alipay_callback(data, db):
|
||||||
raise HTTPException(status_code=400, detail="签名验证失败")
|
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")
|
order_no = data.get("out_trade_no")
|
||||||
result = await db.execute(
|
trade_no = data.get("trade_no", "")
|
||||||
select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1)
|
if order_no:
|
||||||
)
|
await process_payment_success_by_order_no(db, order_no, trade_no)
|
||||||
order = result.scalar_one_or_none()
|
|
||||||
if order:
|
|
||||||
await process_payment_success(db, order.id)
|
|
||||||
return "success"
|
return "success"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class Settings(BaseSettings):
|
|||||||
ALIPAY_APP_ID: str = ""
|
ALIPAY_APP_ID: str = ""
|
||||||
ALIPAY_PRIVATE_KEY: str = ""
|
ALIPAY_PRIVATE_KEY: str = ""
|
||||||
ALIPAY_PUBLIC_KEY: str = ""
|
ALIPAY_PUBLIC_KEY: str = ""
|
||||||
|
ALIPAY_NOTIFY_URL: str = ""
|
||||||
PAYMENT_MOCK: bool = True
|
PAYMENT_MOCK: bool = True
|
||||||
|
|
||||||
STORAGE_TYPE: str = "local"
|
STORAGE_TYPE: str = "local"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class RechargeRequest(BaseModel):
|
class RechargeRequest(BaseModel):
|
||||||
plan: str # package id
|
plan: str # package id
|
||||||
|
method: str = "wechat" # "wechat" or "alipay"
|
||||||
|
|
||||||
|
|
||||||
class PaymentOrderOut(BaseModel):
|
class PaymentOrderOut(BaseModel):
|
||||||
@@ -12,5 +13,6 @@ class PaymentOrderOut(BaseModel):
|
|||||||
credits: float
|
credits: float
|
||||||
payment_method: str
|
payment_method: str
|
||||||
status: str
|
status: str
|
||||||
|
qr_url: str | None = None # Alipay QR code URL (transient, not persisted)
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -1,16 +1,87 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.payment_order import PaymentOrder
|
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
|
||||||
from app.utils.id_gen import generate_id, generate_order_no
|
from app.utils.id_gen import generate_id, generate_order_no
|
||||||
|
|
||||||
logger = logging.getLogger("videogen")
|
logger = logging.getLogger("videogen")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config helpers – read from system_configs table (admin panel)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
|
||||||
|
"""Read all payment_* configs from the database, return as a dict."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key.like("payment_%"))
|
||||||
|
)
|
||||||
|
return {c.key: c.value for c in result.scalars().all()}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
|
||||||
|
"""Check if payment mock mode is enabled (from DB or env)."""
|
||||||
|
db_val = db_configs.get("payment_mock", "")
|
||||||
|
if db_val:
|
||||||
|
return db_val.lower() in ("true", "1", "yes")
|
||||||
|
return settings.PAYMENT_MOCK
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Alipay client (lazy singleton, recreated when config changes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_alipay_client = None
|
||||||
|
_alipay_client_app_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_alipay_client(app_id: str, private_key: str, public_key: str):
|
||||||
|
"""Get or create an Alipay client. Recreated if app_id changes."""
|
||||||
|
global _alipay_client, _alipay_client_app_id
|
||||||
|
|
||||||
|
if _alipay_client is not None and _alipay_client_app_id == app_id:
|
||||||
|
return _alipay_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
from alipay.aop.api.AlipayClientConfig import AlipayClientConfig
|
||||||
|
from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient
|
||||||
|
except ImportError:
|
||||||
|
logger.error(
|
||||||
|
"alipay-sdk-python is not installed. "
|
||||||
|
"Install it with: pip install alipay-sdk-python"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
config = AlipayClientConfig()
|
||||||
|
config.server_url = "https://openapi.alipay.com/gateway.do"
|
||||||
|
config.app_id = app_id
|
||||||
|
config.app_private_key = private_key
|
||||||
|
config.alipay_public_key = public_key
|
||||||
|
config.sign_type = "RSA2"
|
||||||
|
config.charset = "utf-8"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_alipay_client = DefaultAlipayClient(config)
|
||||||
|
_alipay_client_app_id = app_id
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to initialize Alipay client")
|
||||||
|
_alipay_client = None
|
||||||
|
_alipay_client_app_id = None
|
||||||
|
|
||||||
|
return _alipay_client
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create recharge order
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def create_recharge_order(
|
async def create_recharge_order(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -20,7 +91,12 @@ async def create_recharge_order(
|
|||||||
bonus_credits: float = 0.0,
|
bonus_credits: float = 0.0,
|
||||||
method: str = "wechat",
|
method: str = "wechat",
|
||||||
) -> PaymentOrder:
|
) -> PaymentOrder:
|
||||||
"""Create a payment order. In mock mode, immediately completes payment."""
|
"""Create a payment order.
|
||||||
|
|
||||||
|
Reads payment config from the database (admin panel).
|
||||||
|
Returns the order; for Alipay the ``qr_url`` attribute will be populated
|
||||||
|
with the scan-to-pay URL.
|
||||||
|
"""
|
||||||
total_credits = credits + bonus_credits
|
total_credits = credits + bonus_credits
|
||||||
order = PaymentOrder(
|
order = PaymentOrder(
|
||||||
id=generate_id(),
|
id=generate_id(),
|
||||||
@@ -34,7 +110,11 @@ async def create_recharge_order(
|
|||||||
db.add(order)
|
db.add(order)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
if settings.PAYMENT_MOCK:
|
# Read config from database
|
||||||
|
db_configs = await _get_payment_configs(db)
|
||||||
|
mock_mode = _is_mock_mode(db_configs)
|
||||||
|
|
||||||
|
if mock_mode:
|
||||||
# Mock: immediately complete payment
|
# Mock: immediately complete payment
|
||||||
order.status = "paid"
|
order.status = "paid"
|
||||||
order.paid_at = datetime.now()
|
order.paid_at = datetime.now()
|
||||||
@@ -52,57 +132,184 @@ async def create_recharge_order(
|
|||||||
else:
|
else:
|
||||||
# Real payment: delegate to WeChat or Alipay
|
# Real payment: delegate to WeChat or Alipay
|
||||||
if method == "wechat":
|
if method == "wechat":
|
||||||
_create_wechat_order(order)
|
_create_wechat_order(order, db_configs)
|
||||||
elif method == "alipay":
|
elif method == "alipay":
|
||||||
_create_alipay_order(order)
|
qr_url = _create_alipay_order(order, db_configs)
|
||||||
|
if qr_url:
|
||||||
|
# Attach QR URL to the order instance (transient, not persisted)
|
||||||
|
order.qr_url = qr_url # type: ignore[attr-defined]
|
||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
def _create_wechat_order(order: PaymentOrder) -> None:
|
# ---------------------------------------------------------------------------
|
||||||
|
# WeChat (stub)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> None:
|
||||||
"""Create a WeChat Pay order. Stub for real integration."""
|
"""Create a WeChat Pay order. Stub for real integration."""
|
||||||
if not settings.WECHAT_MCH_ID or not settings.WECHAT_API_KEY:
|
mch_id = db_configs.get("payment_wechat_mch_id", "")
|
||||||
logger.warning("WeChat payment config missing (WECHAT_MCH_ID / WECHAT_API_KEY)")
|
api_key = db_configs.get("payment_wechat_api_key", "")
|
||||||
|
if not mch_id or not api_key:
|
||||||
|
logger.warning("WeChat payment config missing in database")
|
||||||
return
|
return
|
||||||
logger.info(
|
logger.info(
|
||||||
f"WeChat order created: mch_id={settings.WECHAT_MCH_ID}, "
|
f"WeChat order created: mch_id={mch_id}, "
|
||||||
f"order_no={order.order_no}, amount={order.amount}"
|
f"order_no={order.order_no}, amount={order.amount}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_alipay_order(order: PaymentOrder) -> None:
|
# ---------------------------------------------------------------------------
|
||||||
"""Create an Alipay order. Stub for real integration."""
|
# Alipay – trade.precreate (当面付 预下单)
|
||||||
if not settings.ALIPAY_APP_ID or not settings.ALIPAY_PRIVATE_KEY:
|
# ---------------------------------------------------------------------------
|
||||||
logger.warning("Alipay payment config missing (ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY)")
|
|
||||||
return
|
|
||||||
logger.info(
|
|
||||||
f"Alipay order created: app_id={settings.ALIPAY_APP_ID}, "
|
|
||||||
f"order_no={order.order_no}, amount={order.amount}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_wechat_callback(data: dict) -> bool:
|
def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None:
|
||||||
|
"""Call Alipay ``trade.precreate`` to obtain a QR code URL.
|
||||||
|
|
||||||
|
Reads all Alipay config from the database (admin panel).
|
||||||
|
Returns the ``qr_code`` URL on success, or ``None`` on failure.
|
||||||
|
"""
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
if not app_id or not private_key:
|
||||||
|
logger.warning("Alipay config missing in database (app_id / private_key)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
client = _get_alipay_client(app_id, private_key, public_key)
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from alipay.aop.api.domain.AlipayTradePrecreateModel import (
|
||||||
|
AlipayTradePrecreateModel,
|
||||||
|
)
|
||||||
|
from alipay.aop.api.request.AlipayTradePrecreateRequest import (
|
||||||
|
AlipayTradePrecreateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
model = AlipayTradePrecreateModel()
|
||||||
|
model.out_trade_no = order.order_no
|
||||||
|
model.total_amount = f"{order.amount:.2f}"
|
||||||
|
model.subject = f"充值订单 {order.order_no}"
|
||||||
|
|
||||||
|
body_parts = []
|
||||||
|
if order.credits > 0:
|
||||||
|
body_parts.append(f"{order.credits}积分")
|
||||||
|
if body_parts:
|
||||||
|
model.body = " ".join(body_parts)
|
||||||
|
|
||||||
|
request = AlipayTradePrecreateRequest()
|
||||||
|
request.biz_model = model
|
||||||
|
if notify_url:
|
||||||
|
request.notify_url = notify_url
|
||||||
|
|
||||||
|
response = client.execute(request)
|
||||||
|
|
||||||
|
if response.code == "10000":
|
||||||
|
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(
|
||||||
|
f"Alipay precreate 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:
|
||||||
|
logger.exception(f"Alipay precreate exception: order_no={order.order_no}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Alipay callback verification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
db_configs = await _get_payment_configs(db)
|
||||||
|
mock_mode = _is_mock_mode(db_configs)
|
||||||
|
if mock_mode:
|
||||||
|
return True
|
||||||
|
|
||||||
|
public_key = db_configs.get("payment_alipay_public_key", "")
|
||||||
|
if not public_key:
|
||||||
|
logger.warning("ALIPAY_PUBLIC_KEY not found in database, cannot verify callback")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
sign = data.get("sign")
|
||||||
|
if not sign:
|
||||||
|
logger.warning("Alipay callback missing 'sign' field")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
logger.warning("Alipay callback signature verification FAILED")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WeChat callback verification (stub)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
|
||||||
"""Verify WeChat payment callback signature."""
|
"""Verify WeChat payment callback signature."""
|
||||||
if settings.PAYMENT_MOCK:
|
db_configs = await _get_payment_configs(db)
|
||||||
|
mock_mode = _is_mock_mode(db_configs)
|
||||||
|
if mock_mode:
|
||||||
return True
|
return True
|
||||||
# Real verification would use WECHAT_API_KEY to verify signature
|
|
||||||
logger.info("WeChat callback verification (real mode not implemented)")
|
logger.info("WeChat callback verification (real mode not implemented)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def verify_alipay_callback(data: dict) -> bool:
|
# ---------------------------------------------------------------------------
|
||||||
"""Verify Alipay payment callback signature."""
|
# Process successful payment
|
||||||
if settings.PAYMENT_MOCK:
|
# ---------------------------------------------------------------------------
|
||||||
return True
|
|
||||||
# Real verification would use ALIPAY_PUBLIC_KEY to verify signature
|
|
||||||
logger.info("Alipay callback verification (real mode not implemented)")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def process_payment_success(db: AsyncSession, order_id: str):
|
async def process_payment_success(db: AsyncSession, order_id: str):
|
||||||
"""Process successful payment: update order and add credits."""
|
"""Process successful payment: update order and add credits."""
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1)
|
select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1)
|
||||||
)
|
)
|
||||||
@@ -120,3 +327,35 @@ async def process_payment_success(db: AsyncSession, order_id: str):
|
|||||||
related_id=order.id,
|
related_id=order.id,
|
||||||
)
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, trade_no: str = ""):
|
||||||
|
"""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
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PaymentOrder).where(PaymentOrder.order_no == order_no).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")
|
||||||
|
return
|
||||||
|
|
||||||
|
order.status = "paid"
|
||||||
|
order.paid_at = datetime.now()
|
||||||
|
if trade_no:
|
||||||
|
order.trade_no = trade_no
|
||||||
|
|
||||||
|
await add_credits(
|
||||||
|
db,
|
||||||
|
order.user_id,
|
||||||
|
order.credits,
|
||||||
|
f"充值成功({order.credits}积分)",
|
||||||
|
related_id=order.id,
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(f"Payment success processed: order_no={order_no}, trade_no={trade_no}")
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ dependencies = [
|
|||||||
pg = ["asyncpg>=0.30.0"]
|
pg = ["asyncpg>=0.30.0"]
|
||||||
redis = ["redis>=5.2.0"]
|
redis = ["redis>=5.2.0"]
|
||||||
celery = ["celery>=5.4.0", "redis>=5.2.0"]
|
celery = ["celery>=5.4.0", "redis>=5.2.0"]
|
||||||
|
alipay = ["alipay-sdk-python>=3.7.1160"]
|
||||||
|
volc = ["volcengine-python-sdk>=1.1.0"]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3.0",
|
"pytest>=8.3.0",
|
||||||
"pytest-asyncio>=0.24.0",
|
"pytest-asyncio>=0.24.0",
|
||||||
|
|||||||
@@ -319,6 +319,14 @@ export async function getRechargePackages(): Promise<any[]> {
|
|||||||
return api.get('/recharge-packages');
|
return api.get('/recharge-packages');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise<any> {
|
||||||
|
return api.post('/payments/recharge', { plan: planId, method });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPaymentOrders(): Promise<any[]> {
|
||||||
|
return api.get('/payments/orders');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getCreditRatios(): Promise<any[]> {
|
export async function getCreditRatios(): Promise<any[]> {
|
||||||
return api.get('/credits/ratios');
|
return api.get('/credits/ratios');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd';
|
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import {
|
import {
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
@@ -21,12 +21,13 @@ import {
|
|||||||
FireFilled,
|
FireFilled,
|
||||||
CrownFilled,
|
CrownFilled,
|
||||||
BankFilled,
|
BankFilled,
|
||||||
QrcodeOutlined,
|
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
|
WechatOutlined,
|
||||||
|
AlipayCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../store/useAuthStore';
|
import { useAuthStore } from '../../store/useAuthStore';
|
||||||
import { getMenuConfigs, getRechargePackages, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
import { getMenuConfigs, getRechargePackages, createRechargeOrder, getPaymentOrders, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
||||||
import NotificationPopup from '../NotificationPopup';
|
import NotificationPopup from '../NotificationPopup';
|
||||||
|
|
||||||
interface MenuConfig {
|
interface MenuConfig {
|
||||||
@@ -88,7 +89,10 @@ const AppLayout: React.FC = () => {
|
|||||||
const [siteName, setSiteName] = useState('VideoGen.AI');
|
const [siteName, setSiteName] = useState('VideoGen.AI');
|
||||||
const [siteLogo, setSiteLogo] = useState('');
|
const [siteLogo, setSiteLogo] = useState('');
|
||||||
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
|
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
|
||||||
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string } | null>(null);
|
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
||||||
|
const [paying, setPaying] = useState(false);
|
||||||
|
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// 监听预览弹窗状态,关闭浮动按钮
|
// 监听预览弹窗状态,关闭浮动按钮
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,6 +177,43 @@ const AppLayout: React.FC = () => {
|
|||||||
setRechargeModalOpen(true);
|
setRechargeModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollingTimerRef.current) {
|
||||||
|
clearInterval(pollingTimerRef.current);
|
||||||
|
pollingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startPolling = useCallback((orderNo: string) => {
|
||||||
|
stopPolling();
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 120; // 2 minutes at 1s interval
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts > maxAttempts) {
|
||||||
|
clearInterval(timer);
|
||||||
|
pollingTimerRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const orders = await getPaymentOrders();
|
||||||
|
const order = orders.find((o: any) => o.order_no === orderNo);
|
||||||
|
if (order && order.status === 'paid') {
|
||||||
|
clearInterval(timer);
|
||||||
|
pollingTimerRef.current = null;
|
||||||
|
message.success('支付成功!积分已到账');
|
||||||
|
useAuthStore.getState().refreshUser();
|
||||||
|
setQrCodeModalOpen(false);
|
||||||
|
setCurrentPaymentInfo(null);
|
||||||
|
setSelectedPlan(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore polling errors
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
pollingTimerRef.current = timer;
|
||||||
|
}, [stopPolling]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
{/* Desktop Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
@@ -516,28 +557,64 @@ const AppLayout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
|
{/* Payment method selection */}
|
||||||
|
<div style={{ marginTop: 20, marginBottom: 8 }}>
|
||||||
|
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||||
|
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||||
|
style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<Radio.Button value="alipay" style={{
|
||||||
|
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||||
|
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||||
|
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||||
|
}}>
|
||||||
|
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||||
|
支付宝
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value="wechat" style={{
|
||||||
|
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||||
|
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||||
|
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||||
|
}}>
|
||||||
|
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||||
|
微信支付
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
||||||
<Button type="primary" size="large" disabled={!selectedPlan}
|
<Button type="primary" size="large" disabled={!selectedPlan} loading={paying}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||||
if (plan) {
|
if (!plan) return;
|
||||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||||
// 生成随机支付内容(模拟微信支付订单号)
|
try {
|
||||||
const orderId = `WX${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
|
setPaying(true);
|
||||||
const paymentContent = JSON.stringify({
|
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||||
orderId,
|
if (paymentMethod === 'alipay' && order.qr_url) {
|
||||||
amount: plan.price,
|
// Alipay: show the real QR code URL from the backend
|
||||||
credits: totalCredits,
|
setCurrentPaymentInfo({
|
||||||
timestamp: Date.now()
|
price: plan.price,
|
||||||
});
|
credits: totalCredits,
|
||||||
setCurrentPaymentInfo({
|
qrCode: order.qr_url,
|
||||||
price: plan.price,
|
method: 'alipay',
|
||||||
credits: totalCredits,
|
});
|
||||||
qrCode: paymentContent
|
setRechargeModalOpen(false);
|
||||||
});
|
setQrCodeModalOpen(true);
|
||||||
setRechargeModalOpen(false);
|
// Start polling for payment status
|
||||||
setQrCodeModalOpen(true);
|
startPolling(order.order_no);
|
||||||
|
} else {
|
||||||
|
// WeChat or mock mode (mock auto-completes, no QR needed)
|
||||||
|
message.success('充值成功!积分已到账');
|
||||||
|
useAuthStore.getState().refreshUser();
|
||||||
|
setRechargeModalOpen(false);
|
||||||
|
setSelectedPlan(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || '创建订单失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setPaying(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
@@ -554,7 +631,7 @@ const AppLayout: React.FC = () => {
|
|||||||
{/* QR Code Payment Modal */}
|
{/* QR Code Payment Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
open={qrCodeModalOpen}
|
open={qrCodeModalOpen}
|
||||||
onCancel={() => { setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
onCancel={() => { stopPolling(); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={400}
|
width={400}
|
||||||
closable={false}
|
closable={false}
|
||||||
@@ -567,15 +644,25 @@ const AppLayout: React.FC = () => {
|
|||||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
background: currentPaymentInfo?.method === 'alipay'
|
||||||
|
? 'linear-gradient(135deg, #1677ff, #0958d9)'
|
||||||
|
: 'linear-gradient(135deg, #07c160, #06ae56)',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
margin: '0 auto 12px',
|
margin: '0 auto 12px',
|
||||||
}}>
|
}}>
|
||||||
<QrcodeOutlined style={{ fontSize: 24, color: '#fff' }} />
|
{currentPaymentInfo?.method === 'alipay'
|
||||||
|
? <AlipayCircleOutlined style={{ fontSize: 24, color: '#fff' }} />
|
||||||
|
: <WechatOutlined style={{ fontSize: 24, color: '#fff' }} />}
|
||||||
</div>
|
</div>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>微信支付</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>请使用微信扫描二维码完成支付</Typography.Text>
|
{currentPaymentInfo?.method === 'alipay' ? '支付宝支付' : '微信支付'}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>
|
||||||
|
{currentPaymentInfo?.method === 'alipay'
|
||||||
|
? '请使用支付宝扫描二维码完成支付'
|
||||||
|
: '请使用微信扫描二维码完成支付'}
|
||||||
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR Code */}
|
{/* QR Code */}
|
||||||
@@ -640,32 +727,15 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Buttons */}
|
{/* Footer Buttons */}
|
||||||
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => { setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
block
|
||||||
style={{ flex: 1, borderRadius: 10 }}
|
onClick={() => { stopPolling(); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setSelectedPlan(null); }}
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
>
|
>
|
||||||
取消支付
|
取消支付
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
onClick={() => {
|
|
||||||
message.success('支付成功!积分已到账');
|
|
||||||
setQrCodeModalOpen(false);
|
|
||||||
setCurrentPaymentInfo(null);
|
|
||||||
setSelectedPlan(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
已完成支付
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user