0722225c62
后端: - PointTransaction 模型添加 duration 字段(float, nullable) - PointTransactionItem schema 添加 duration - consume() 新增 duration 参数,写入流水记录 - 各业务 description 统一简化为【脚本生成】【配音合成】等格式 - duration 类业务(tts/video)传入实际秒数 - Alembic 迁移: 95eb1a1c0af9_add_duration_to_point_transaction 前端: - PointTransaction 类型添加 duration - UsageDetail: 来源列 → 时长列(有值显示 xs,无值显示 -) - 说明列直接显示后端返回的简化描述
521 lines
17 KiB
Python
521 lines
17 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(),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""获取当前用户积分流水记录"""
|
||
items = await point_transaction.get_by_user_id(
|
||
db,
|
||
user_id=current_user.id,
|
||
skip=pagination.offset,
|
||
limit=pagination.page_size,
|
||
)
|
||
|
||
# 由于 point_transaction CRUD 没有 count 方法,这里用 items 长度近似
|
||
# 实际需要时可以在 CRUD 加 count 方法
|
||
total = len(items)
|
||
|
||
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
|
||
|
||
# 调用微信支付统一下单
|
||
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),
|
||
)
|
||
|
||
# 记录微信返回
|
||
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,
|
||
),
|
||
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("/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,前端应拦截并引导充值。
|
||
"""
|
||
# 余额预检:不允许欠费
|
||
balance_info = await point_service.get_user_balance(db, current_user.id)
|
||
if balance_info["balance"] < request.points:
|
||
raise HTTPException(
|
||
status_code=402,
|
||
detail=f"积分不足,当前余额 {balance_info['balance']},需要 {request.points} 积分",
|
||
)
|
||
|
||
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}】",
|
||
)
|
||
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,
|
||
)
|
||
)
|
||
|
||
|
||
# ── 内部管理接口(后续可加管理员权限检查)─────────────
|
||
|
||
@router.post("/admin/recharge", response_model=ApiResponse[dict])
|
||
async def admin_recharge(
|
||
user_id: str,
|
||
points: int,
|
||
source: str = "compensation",
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""
|
||
管理员直接充值(用于补偿、活动赠送等)
|
||
|
||
需要管理员权限,当前先做接口占位。
|
||
"""
|
||
# TODO: 加管理员权限检查
|
||
try:
|
||
tx = await point_service.recharge(
|
||
db,
|
||
user_id=user_id,
|
||
points=points,
|
||
source=source,
|
||
description=f"管理员操作: {source}",
|
||
)
|
||
await db.commit()
|
||
return success_response(
|
||
data={"transaction_id": tx.id, "points": points},
|
||
message=f"成功充值 {points} 积分",
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|