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:
小鱼开发
2026-05-09 21:29:35 +08:00
parent 0722225c62
commit 04e467e433
77 changed files with 1475 additions and 629 deletions
+92 -52
View File
@@ -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))