04e467e433
后端: - 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试 - recharge() 加 order_id 幂等保护,防重复充值 - time_expire 使用北京时间(UTC+8),修复时区 bug - 充值档位后端配置化(points-config.yaml + /recharge-options API) - 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等) 前端: - 充值弹窗:自动轮询 + 【我已支付】手动兜底 - 二维码倒计时显示,过期后遮罩 + 刷新按钮 - 充值档位从后端动态加载 - 去掉 select/qrcode 弹窗标题,金额红色突出显示 - 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等) - Modal 关闭按钮独立于 title 显示
561 lines
19 KiB
Python
561 lines
19 KiB
Python
"""
|
||
积分系统 API 路由
|
||
=================
|
||
|
||
提供积分查询、充值、消费、流水查询等功能。
|
||
"""
|
||
|
||
import logging
|
||
from datetime import UTC, datetime
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from fastapi.responses import JSONResponse
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.deps import get_current_user, get_db
|
||
from app.crud.point_recharge_order import point_recharge_order
|
||
from app.crud.point_transaction import point_transaction
|
||
from app.models.user import User
|
||
from app.schemas.common import ApiResponse, PaginationParams, success_response
|
||
from app.schemas.point import (
|
||
ConsumeRequest,
|
||
PointBalanceResponse,
|
||
PointTransactionItem,
|
||
PointTransactionListResponse,
|
||
RechargeOrderItem,
|
||
RechargeOrderListResponse,
|
||
RechargeRequest,
|
||
RechargeResponse,
|
||
)
|
||
from app.services import point_service
|
||
from app.services.wxpay_service import WechatPayError, get_wxpay_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/points", tags=["Points"])
|
||
|
||
|
||
# ── 余额查询 ──────────────────────────────────────────
|
||
|
||
@router.get("/balance", response_model=ApiResponse[PointBalanceResponse])
|
||
async def get_balance(
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""获取当前用户积分余额"""
|
||
balance = await point_service.get_user_balance(db, user_id=current_user.id)
|
||
return success_response(data=PointBalanceResponse(**balance))
|
||
|
||
|
||
# ── 流水查询 ──────────────────────────────────────────
|
||
|
||
@router.get("/transactions", response_model=ApiResponse[PointTransactionListResponse])
|
||
async def list_transactions(
|
||
pagination: PaginationParams = Depends(),
|
||
tx_type: str | None = None,
|
||
category: str | None = None,
|
||
start_time: str | None = None,
|
||
end_time: str | None = None,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
获取当前用户积分流水记录(支持筛选)
|
||
|
||
- tx_type: consume / recharge / expire
|
||
- category: 脚本生成 / 配音合成 / 视频生成 / 压制成片 / 字幕烧录 / 封面设计 / 充值
|
||
- start_time / end_time: ISO 8601 格式,时间范围最多 30 天
|
||
"""
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
# 解析时间范围
|
||
parsed_start: datetime | None = None
|
||
parsed_end: datetime | None = None
|
||
|
||
if start_time:
|
||
try:
|
||
parsed_start = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="start_time 格式错误,应为 ISO 8601")
|
||
|
||
if end_time:
|
||
try:
|
||
parsed_end = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="end_time 格式错误,应为 ISO 8601")
|
||
|
||
# 限制时间范围最多 30 天
|
||
if parsed_start and parsed_end:
|
||
if (parsed_end - parsed_start) > timedelta(days=30):
|
||
raise HTTPException(status_code=400, detail="时间范围最多 30 天")
|
||
elif parsed_start and not parsed_end:
|
||
parsed_end = parsed_start + timedelta(days=30)
|
||
elif parsed_end and not parsed_start:
|
||
parsed_start = parsed_end - timedelta(days=30)
|
||
|
||
items = await point_transaction.get_by_user_id(
|
||
db,
|
||
user_id=current_user.id,
|
||
skip=pagination.offset,
|
||
limit=pagination.page_size,
|
||
tx_type=tx_type,
|
||
category=category,
|
||
start_time=parsed_start,
|
||
end_time=parsed_end,
|
||
)
|
||
|
||
total = await point_transaction.count_by_user_id(
|
||
db,
|
||
user_id=current_user.id,
|
||
tx_type=tx_type,
|
||
category=category,
|
||
start_time=parsed_start,
|
||
end_time=parsed_end,
|
||
)
|
||
|
||
return success_response(
|
||
data=PointTransactionListResponse(
|
||
items=[PointTransactionItem.model_validate(t) for t in items],
|
||
total=total,
|
||
skip=pagination.offset,
|
||
limit=pagination.page_size,
|
||
)
|
||
)
|
||
|
||
|
||
# ── 充值 ──────────────────────────────────────────────
|
||
|
||
@router.post("/recharge", response_model=ApiResponse[RechargeResponse])
|
||
async def create_recharge_order(
|
||
request: RechargeRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
|
||
):
|
||
"""
|
||
创建积分充值订单(微信支付 Native 扫码)
|
||
|
||
1. 创建本地订单记录
|
||
2. 调用微信统一下单获取二维码链接 code_url
|
||
3. 前端用 code_url 生成二维码,用户微信扫码支付
|
||
4. 支付成功后微信异步通知 /recharge/notify
|
||
"""
|
||
logger.info(
|
||
f"[Points] 用户 {current_user.id} 发起充值: {request.points} 积分, "
|
||
f"金额: {request.amount_rmb} 分"
|
||
)
|
||
|
||
# 创建待支付订单
|
||
from app.models.point_recharge_order import PointRechargeOrder
|
||
|
||
order = PointRechargeOrder(
|
||
user_id=current_user.id,
|
||
points=request.points,
|
||
amount_rmb=request.amount_rmb,
|
||
status="pending",
|
||
trade_type="NATIVE",
|
||
)
|
||
db.add(order)
|
||
await db.flush()
|
||
|
||
# 生成商户订单号
|
||
out_trade_no = f"MJZ{order.id:012d}"
|
||
order.out_trade_no = out_trade_no
|
||
|
||
# 二维码有效期 5 分钟(与前端轮询对齐)
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
# 前端倒计时用 UTC ISO 格式,不受服务器时区影响
|
||
expire_at = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||
# 微信 time_expire 要求北京时间(UTC+8),格式 yyyyMMddHHmmss
|
||
beijing_tz = timezone(timedelta(hours=8))
|
||
time_expire = expire_at.astimezone(beijing_tz).strftime("%Y%m%d%H%M%S")
|
||
|
||
# 调用微信支付统一下单
|
||
try:
|
||
from app.services.wxpay_service import WechatPayError, get_wxpay_service
|
||
|
||
wxpay = get_wxpay_service()
|
||
|
||
# 从 request 获取 http_client(FastAPI 依赖注入方式获取不了,改用直接引用)
|
||
# 这里用默认的 httpx client
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client:
|
||
wx_result = await wxpay.native_order(
|
||
client,
|
||
description=f"积分充值 {request.points} 积分",
|
||
out_trade_no=out_trade_no,
|
||
amount=request.amount_rmb,
|
||
attach=str(current_user.id),
|
||
time_expire=time_expire,
|
||
)
|
||
|
||
# 记录微信返回
|
||
order.request_response = str(wx_result)
|
||
code_url = wx_result.get("code_url")
|
||
|
||
if not code_url:
|
||
logger.error(f"[Points] 微信统一下单未返回 code_url: {wx_result}")
|
||
order.status = "failed"
|
||
order.error_msg = "微信未返回二维码链接"
|
||
raise HTTPException(status_code=500, detail="微信支付下单失败")
|
||
|
||
order.prepay_id = wx_result.get("prepay_id")
|
||
await db.commit()
|
||
|
||
return success_response(
|
||
data=RechargeResponse(
|
||
order_id=order.id,
|
||
points=request.points,
|
||
amount_rmb=request.amount_rmb,
|
||
code_url=code_url,
|
||
expire_at=expire_at.isoformat(),
|
||
),
|
||
message="充值订单已创建,请扫描微信二维码完成支付",
|
||
)
|
||
|
||
except WechatPayError as e:
|
||
logger.error(f"[Points] 微信统一下单失败: {e}")
|
||
order.status = "failed"
|
||
order.error_code = e.code
|
||
order.error_msg = str(e)
|
||
await db.commit()
|
||
raise HTTPException(status_code=500, detail=f"微信支付下单失败: {e}")
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.exception(f"[Points] 创建充值订单异常: {e}")
|
||
order.status = "failed"
|
||
order.error_msg = str(e)
|
||
await db.commit()
|
||
raise HTTPException(status_code=500, detail="创建充值订单失败")
|
||
|
||
|
||
@router.post("/recharge/notify")
|
||
async def handle_wxpay_notify(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
微信支付回调通知
|
||
|
||
微信服务器在支付成功后推送通知到此端点。
|
||
验证签名 → 更新订单 → 给用户充值积分。
|
||
必须返回 200 且 body 为 {"code": "SUCCESS"},否则微信会重试。
|
||
|
||
注意:此接口不设置 response_model,直接返回原始 JSON,
|
||
避免 FastAPI 用 ApiResponse 包装导致微信无法识别。
|
||
"""
|
||
|
||
def _wx_response() -> JSONResponse:
|
||
"""构造微信要求的回调响应"""
|
||
return JSONResponse(content={"code": "SUCCESS", "message": "OK"})
|
||
|
||
# 读取回调原始 body(APIv2 为 XML 格式)
|
||
body_bytes = await request.body()
|
||
body_str = body_bytes.decode("utf-8")
|
||
|
||
logger.info(f"[WechatPay] 收到回调通知: body={body_str[:200]}")
|
||
|
||
wxpay = get_wxpay_service()
|
||
|
||
# 解析 XML
|
||
try:
|
||
notify_data = wxpay._xml_to_dict(body_str)
|
||
except Exception as e:
|
||
logger.error(f"[WechatPay] XML 解析失败: {e}, body={body_str[:200]}")
|
||
return _wx_response()
|
||
|
||
# 验签(APIv2:从 XML 中的 sign 字段验 MD5 签名)
|
||
try:
|
||
verified = wxpay.verify_notify(notify_data)
|
||
except WechatPayError as e:
|
||
logger.error(f"[WechatPay] 回调验签失败: {e}")
|
||
return _wx_response()
|
||
|
||
if not verified:
|
||
logger.error("[WechatPay] 回调签名验证未通过")
|
||
return _wx_response()
|
||
|
||
# APIv2 回调不加密,直接提取字段
|
||
out_trade_no = notify_data.get("out_trade_no")
|
||
wx_order_no = notify_data.get("transaction_id")
|
||
trade_state = notify_data.get("result_code", "")
|
||
|
||
if not out_trade_no:
|
||
logger.error("[WechatPay] 回调缺少 out_trade_no")
|
||
return _wx_response()
|
||
|
||
# 查找订单
|
||
order = await point_recharge_order.get_by_out_trade_no(
|
||
db, out_trade_no=out_trade_no
|
||
)
|
||
if not order:
|
||
logger.error(f"[WechatPay] 回调订单不存在: {out_trade_no}")
|
||
return _wx_response()
|
||
|
||
# 记录回调原始内容
|
||
order.notify_raw = body_str
|
||
order.notify_verified = True
|
||
order.wx_order_no = wx_order_no
|
||
|
||
# 只处理支付成功状态
|
||
if trade_state != "SUCCESS":
|
||
logger.info(f"[WechatPay] 订单 {out_trade_no} 状态: {trade_state},暂不处理")
|
||
if trade_state in ("CLOSED", "REVOKED"):
|
||
order.status = "closed"
|
||
await db.commit()
|
||
return _wx_response()
|
||
|
||
# 幂等:已处理过的订单不再重复充值
|
||
if order.status == "paid":
|
||
logger.info(f"[WechatPay] 订单 {out_trade_no} 已处理,跳过")
|
||
await db.commit() # 提交 notify_raw 等记录
|
||
return _wx_response()
|
||
|
||
# 更新订单状态并充值积分(同一事务)
|
||
try:
|
||
order.status = "paid"
|
||
order.paid_at = datetime.now(UTC)
|
||
|
||
# 给用户充值积分
|
||
await point_service.recharge(
|
||
db,
|
||
user_id=order.user_id,
|
||
points=order.points,
|
||
source="wxpay",
|
||
description=f"微信支付充值 {order.points} 积分",
|
||
order_id=order.id,
|
||
)
|
||
|
||
await db.commit()
|
||
|
||
logger.info(
|
||
f"[WechatPay] 订单 {out_trade_no} 处理完成,"
|
||
f"用户 {order.user_id} 充值 {order.points} 积分"
|
||
)
|
||
|
||
except Exception as e:
|
||
await db.rollback()
|
||
logger.exception(f"[WechatPay] 订单 {out_trade_no} 充值积分失败: {e}")
|
||
# 记录错误但不抛出,返回 SUCCESS 避免微信重试
|
||
order.error_msg = f"充值积分失败: {e}"
|
||
await db.commit()
|
||
|
||
return _wx_response()
|
||
|
||
|
||
@router.get("/recharge/query/{order_id}", response_model=ApiResponse[dict])
|
||
async def query_recharge_status(
|
||
order_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
查询充值订单支付状态(前端轮询用)
|
||
|
||
如果订单仍在 pending 状态,会主动查询微信支付确认最新状态。
|
||
"""
|
||
|
||
order = await point_recharge_order.get(db, id=order_id)
|
||
if not order or order.user_id != current_user.id:
|
||
raise HTTPException(status_code=404, detail="订单不存在")
|
||
|
||
# 如果是 pending,主动查询微信支付
|
||
if order.status == "pending" and order.out_trade_no:
|
||
try:
|
||
from app.services.wxpay_service import WechatPayError, get_wxpay_service
|
||
|
||
wxpay = get_wxpay_service()
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client:
|
||
wx_result = await wxpay.query_order(
|
||
client, out_trade_no=order.out_trade_no
|
||
)
|
||
|
||
order.query_result = str(wx_result)
|
||
trade_state = wx_result.get("trade_state", "")
|
||
|
||
if trade_state == "SUCCESS" and order.status != "paid":
|
||
# 支付成功但本地未处理,补发积分
|
||
order.status = "paid"
|
||
order.paid_at = datetime.now(UTC)
|
||
order.wx_order_no = wx_result.get("transaction_id")
|
||
|
||
await point_service.recharge(
|
||
db,
|
||
user_id=order.user_id,
|
||
points=order.points,
|
||
source="wxpay",
|
||
description=f"微信支付充值 {order.points} 积分(主动查询补单)",
|
||
order_id=order.id,
|
||
)
|
||
logger.info(
|
||
f"[Points] 订单 {order.out_trade_no} 通过主动查询确认支付成功,"
|
||
f"补发 {order.points} 积分"
|
||
)
|
||
|
||
elif trade_state in ("CLOSED", "REVOKED"):
|
||
order.status = "closed"
|
||
|
||
await db.commit()
|
||
|
||
except WechatPayError as e:
|
||
logger.warning(f"[Points] 主动查询订单 {order.out_trade_no} 失败: {e}")
|
||
except Exception as e:
|
||
logger.exception(f"[Points] 主动查询订单异常: {e}")
|
||
|
||
return success_response(
|
||
data={
|
||
"order_id": order.id,
|
||
"status": order.status,
|
||
"points": order.points,
|
||
"amount_rmb": order.amount_rmb,
|
||
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
|
||
"wx_order_no": order.wx_order_no,
|
||
}
|
||
)
|
||
|
||
|
||
# ── 充值档位查询 ──────────────────────────────────────
|
||
|
||
@router.get("/recharge-options", response_model=ApiResponse[list[dict]])
|
||
async def get_recharge_options(
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
获取充值档位配置(由后端控制,支持积分赠送)。
|
||
|
||
前端充值弹窗调用此接口展示可选档位,无需硬编码。
|
||
"""
|
||
options = point_service.get_recharge_options()
|
||
return success_response(data=options, message="获取充值档位成功")
|
||
|
||
|
||
# ── 积分预估查询 ──────────────────────────────────────
|
||
|
||
@router.get("/cost")
|
||
async def get_cost(
|
||
source_type: str,
|
||
seconds: int = 0,
|
||
char_count: int = 0,
|
||
total_seconds: int = 0,
|
||
input_seconds: int = 0,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
查询某操作所需积分(预估上限和实际计费规则),并附带余额检查。
|
||
|
||
用于前端在执行业务前预估所需积分,做余额检查。
|
||
"""
|
||
try:
|
||
# 构建预估参数
|
||
estimate_param = {}
|
||
if source_type == "tts":
|
||
estimate_param["char_count"] = char_count
|
||
elif source_type in ("video", "caption"):
|
||
estimate_param["input_seconds"] = input_seconds
|
||
elif source_type == "compose":
|
||
estimate_param["total_seconds"] = total_seconds
|
||
|
||
estimated = point_service._estimate_max_cost(source_type, estimate_param)
|
||
|
||
# 实际计费(按传入秒数计算)
|
||
actual_param = {"seconds": seconds}
|
||
actual = point_service._calculate_cost(source_type, actual_param)
|
||
|
||
# 余额检查
|
||
balance_info = await point_service.check_balance(
|
||
db, current_user.id, required_points=estimated
|
||
)
|
||
|
||
return success_response(
|
||
data={
|
||
"source_type": source_type,
|
||
"estimated_points": estimated,
|
||
"actual_points": actual,
|
||
"sufficient": balance_info["sufficient"],
|
||
"balance": balance_info["balance"],
|
||
"required": balance_info["required"],
|
||
},
|
||
message="积分预估查询成功",
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
|
||
|
||
# ── 直接消费扣费(前端/Rust 层调用)───────────────────
|
||
|
||
@router.post("/consume", response_model=ApiResponse[dict])
|
||
async def consume_points(
|
||
request: ConsumeRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
直接消费积分(不经过冻结)
|
||
|
||
用于 Rust/前端层业务在执行本地操作前扣费:
|
||
- compose(压制成片)
|
||
- subtitle_burn(字幕烧录)
|
||
- cover_design(封面设计)
|
||
|
||
余额不足时返回 402,前端应拦截并引导充值。
|
||
"""
|
||
try:
|
||
tx = await point_service.consume(
|
||
db,
|
||
user_id=current_user.id,
|
||
points=request.points,
|
||
source_type=request.source_type,
|
||
source_id=request.source_id,
|
||
description=f"【{request.description or request.source_type}】",
|
||
allow_negative=False,
|
||
)
|
||
except ValueError as e:
|
||
# 余额不足(在同一事务内判断,避免竞态)
|
||
if "积分不足" in str(e):
|
||
raise HTTPException(status_code=402, detail=str(e))
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
await db.commit()
|
||
|
||
return success_response(
|
||
data={
|
||
"transaction_id": tx.id,
|
||
"consumed_points": tx.amount,
|
||
"balance_after": tx.balance_after,
|
||
"source_type": tx.source_type,
|
||
},
|
||
message="消费成功",
|
||
)
|
||
|
||
|
||
# ── 充值订单查询 ──────────────────────────────────────
|
||
|
||
@router.get("/orders", response_model=ApiResponse[RechargeOrderListResponse])
|
||
async def list_recharge_orders(
|
||
pagination: PaginationParams = Depends(),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""获取当前用户充值订单列表"""
|
||
items = await point_recharge_order.get_by_user_id(
|
||
db,
|
||
user_id=current_user.id,
|
||
skip=pagination.offset,
|
||
limit=pagination.page_size,
|
||
)
|
||
|
||
total = len(items)
|
||
|
||
return success_response(
|
||
data=RechargeOrderListResponse(
|
||
items=[RechargeOrderItem.model_validate(o) for o in items],
|
||
total=total,
|
||
skip=pagination.offset,
|
||
limit=pagination.page_size,
|
||
)
|
||
)
|
||
|
||
|