feat(points): 积分系统收尾 + 充值弹窗改造 + 命名统一
后端: - 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试 - recharge() 加 order_id 幂等保护,防重复充值 - time_expire 使用北京时间(UTC+8),修复时区 bug - 充值档位后端配置化(points-config.yaml + /recharge-options API) - 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等) 前端: - 充值弹窗:自动轮询 + 【我已支付】手动兜底 - 二维码倒计时显示,过期后遮罩 + 刷新按钮 - 充值档位从后端动态加载 - 去掉 select/qrcode 弹窗标题,金额红色突出显示 - 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等) - Modal 关闭按钮独立于 title 显示
This commit is contained in:
@@ -52,20 +52,66 @@ async def get_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,
|
||||
)
|
||||
|
||||
# 由于 point_transaction CRUD 没有 count 方法,这里用 items 长度近似
|
||||
# 实际需要时可以在 CRUD 加 count 方法
|
||||
total = len(items)
|
||||
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(
|
||||
@@ -116,6 +162,15 @@ async def create_recharge_order(
|
||||
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
|
||||
@@ -131,6 +186,7 @@ async def create_recharge_order(
|
||||
out_trade_no=out_trade_no,
|
||||
amount=request.amount_rmb,
|
||||
attach=str(current_user.id),
|
||||
time_expire=time_expire,
|
||||
)
|
||||
|
||||
# 记录微信返回
|
||||
@@ -152,6 +208,7 @@ async def create_recharge_order(
|
||||
points=request.points,
|
||||
amount_rmb=request.amount_rmb,
|
||||
code_url=code_url,
|
||||
expire_at=expire_at.isoformat(),
|
||||
),
|
||||
message="充值订单已创建,请扫描微信二维码完成支付",
|
||||
)
|
||||
@@ -359,6 +416,21 @@ async def query_recharge_status(
|
||||
)
|
||||
|
||||
|
||||
# ── 充值档位查询 ──────────────────────────────────────
|
||||
|
||||
@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")
|
||||
@@ -424,28 +496,27 @@ async def consume_points(
|
||||
直接消费积分(不经过冻结)
|
||||
|
||||
用于 Rust/前端层业务在执行本地操作前扣费:
|
||||
- compose(视频合成)
|
||||
- subtitle_burn(字幕压制)
|
||||
- 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} 积分",
|
||||
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,
|
||||
)
|
||||
|
||||
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}】",
|
||||
)
|
||||
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(
|
||||
@@ -487,34 +558,3 @@ async def list_recharge_orders(
|
||||
)
|
||||
|
||||
|
||||
# ── 内部管理接口(后续可加管理员权限检查)─────────────
|
||||
|
||||
@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))
|
||||
|
||||
Reference in New Issue
Block a user