Files
meijiaka-zy/python-api/app/services/point_service.py
T
小鱼开发 0722225c62 feat(points): 积分流水表支持时长显示,说明字段简化
后端:
- PointTransaction 模型添加 duration 字段(float, nullable)
- PointTransactionItem schema 添加 duration
- consume() 新增 duration 参数,写入流水记录
- 各业务 description 统一简化为【脚本生成】【配音合成】等格式
- duration 类业务(tts/video)传入实际秒数
- Alembic 迁移: 95eb1a1c0af9_add_duration_to_point_transaction

前端:
- PointTransaction 类型添加 duration
- UsageDetail: 来源列 → 时长列(有值显示 xs,无值显示 -)
- 说明列直接显示后端返回的简化描述
2026-05-09 17:08:50 +08:00

414 lines
12 KiB
Python

"""
积分系统 Service 层
===================
核心能力:
1. 余额查询
2. 充值(直接到账 / 微信回调后到账)
3. 消费(后置扣费:执行业务 → 出结果 → 直接扣费)
4. 过期回收
5. 流水记录
设计原则:
- 所有业务操作在一个事务内完成(balance + batch + transaction 三者原子性)。
- FIFO 批次消耗:按 expired_at 升序扣减。
- 后置扣费模式:先执行业务,出结果后按实际消耗扣费。
- 允许欠费(单次业务实际消耗超出预估上限),但欠费后不可继续使用。
注意:本 Service 不自行 commit,由调用方(API 层)通过 FastAPI Depends 注入的
Session 统一提交。所有操作在调用方事务内原子执行。
"""
from __future__ import annotations
import math
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.point_batch import PointBatch
from app.models.point_transaction import PointTransaction
from app.models.user_point import UserPoint
if TYPE_CHECKING:
from uuid import UUID
# ── 配置加载 ──────────────────────────────────────────
_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "points-config.yaml"
def _load_points_config() -> dict:
"""加载积分计费配置。服务启动时读取一次,后续内存中使用。"""
if not _CONFIG_PATH.exists():
raise FileNotFoundError(f"积分配置文件不存在: {_CONFIG_PATH}")
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
# 合并为统一的查询字典:source_type -> {"mode": "fixed|duration|free", ...}
merged: dict[str, dict] = {}
for key, points in cfg.get("fixed_costs", {}).items():
merged[key] = {"mode": "fixed", "points": points}
for key, rule in cfg.get("duration_based_costs", {}).items():
merged[key] = {"mode": "duration", **rule}
for key in cfg.get("free_services", []):
merged[key] = {"mode": "free", "points": 0}
return merged
POINTS_CONFIG: dict[str, dict] = _load_points_config()
EXPIRATION_DAYS = 180
def _now() -> datetime:
"""返回带时区的当前时间"""
return datetime.now(UTC)
def _calculate_cost(source_type: str, param: dict | None = None) -> int:
"""根据消费类型和实际结果参数计算所需积分(后置扣费时使用)"""
if source_type not in POINTS_CONFIG:
raise ValueError(f"未知的消费类型: {source_type}")
cfg = POINTS_CONFIG[source_type]
mode = cfg["mode"]
if mode == "free":
return 0
if mode == "fixed":
return cfg["points"]
if mode == "duration":
if param is None:
raise ValueError(f"消费类型 {source_type} 需要提供参数才能计算积分")
seconds = param.get("seconds", 0)
min_points = cfg.get("min_points", 1)
if "divisor" in cfg:
return max(min_points, math.ceil(seconds / cfg["divisor"]))
if "multiplier" in cfg:
return max(min_points, math.ceil(seconds) * cfg["multiplier"])
raise ValueError(f"消费类型 {source_type} 缺少计算规则")
def _estimate_max_cost(source_type: str, param: dict | None = None) -> int:
"""
预估消费上限(执行业务前检查余额用)。
按秒计费业务取保守估计,确保实际消耗不会超过预估上限。
"""
if source_type not in POINTS_CONFIG:
raise ValueError(f"未知的消费类型: {source_type}")
cfg = POINTS_CONFIG[source_type]
mode = cfg["mode"]
if mode == "free":
return 0
if mode == "fixed":
return cfg["points"]
if mode == "duration":
if param is None:
raise ValueError(f"消费类型 {source_type} 需要提供参数才能预估积分")
est = cfg.get("estimation", {})
min_points = cfg.get("min_points", 1)
if "seconds_per_char" in est:
# TTS 模式:字数 → 预估秒数
char_count = param.get("char_count", 0)
if char_count <= 0:
raise ValueError("TTS 预估需要提供 char_count 参数")
estimated_seconds = char_count * est["seconds_per_char"]
if "divisor" in cfg:
return max(min_points, math.ceil(estimated_seconds / cfg["divisor"]))
if est.get("use_input_seconds"):
# 视频模式:直接使用输入秒数作为预估上限
seconds = param.get("input_seconds", 0)
if seconds <= 0:
raise ValueError("video 预估需要提供 input_seconds 参数")
if "multiplier" in cfg:
return max(min_points, math.ceil(seconds) * cfg["multiplier"])
raise ValueError(f"消费类型 {source_type} 缺少预估规则")
# ── 余额查询 ──────────────────────────────────────────
async def get_user_balance(db: AsyncSession, user_id: UUID | str) -> dict:
"""获取用户积分余额快照"""
result = await db.execute(
select(UserPoint).where(UserPoint.user_id == user_id)
)
up = result.scalar_one_or_none()
if not up:
return {
"balance": 0,
"total_recharged": 0,
"total_consumed": 0,
"total_expired": 0,
}
return {
"balance": up.balance,
"total_recharged": up.total_recharged,
"total_consumed": up.total_consumed,
"total_expired": up.total_expired,
}
async def check_balance(
db: AsyncSession,
user_id: UUID | str,
required_points: int = 0,
) -> dict:
"""
检查用户余额是否足够。
:param required_points: 需要的积分数量
:return: {"sufficient": bool, "balance": int, "required": int}
"""
balance_info = await get_user_balance(db, user_id)
balance = balance_info["balance"]
return {
"sufficient": balance >= required_points,
"balance": balance,
"required": required_points,
}
# ── 充值 ──────────────────────────────────────────────
async def recharge(
db: AsyncSession,
*,
user_id: UUID | str,
points: int,
source: str,
description: str = "",
order_id: int | None = None,
batch_expired_at: datetime | None = None,
) -> PointTransaction:
"""
直接给用户账户充值积分。
:param points: 正整数,充值积分数量
:param source: wxpay / invite / gift / compensation
:param order_id: 关联的充值订单 ID(仅 wxpay 时填)
:param batch_expired_at: 该批次过期时间,默认 180 天后
"""
if points <= 0:
raise ValueError("充值积分必须大于 0")
now = _now()
# 1. 获取或创建用户积分账户
result = await db.execute(
select(UserPoint).where(UserPoint.user_id == user_id)
)
up = result.scalar_one_or_none()
if not up:
up = UserPoint(
user_id=user_id,
balance=0,
total_recharged=0,
total_consumed=0,
total_expired=0,
)
db.add(up)
await db.flush()
# 2. 增加余额
balance_before = up.balance
up.balance += points
up.total_recharged += points
# 3. 写入批次
expired_at = batch_expired_at or (now + timedelta(days=EXPIRATION_DAYS))
batch = PointBatch(
user_id=user_id,
amount=points,
remaining=points,
expired_at=expired_at,
source=source,
)
db.add(batch)
await db.flush()
# 4. 写流水
tx = PointTransaction(
user_id=user_id,
type="recharge",
amount=points,
balance_before=balance_before,
balance_after=up.balance,
source_type=source,
source_id=str(order_id) if order_id else None,
batch_id=batch.id,
description=description or f"{source} 充值 {points} 积分",
)
db.add(tx)
return tx
# ── 消费(后置扣费)───────────────────────────────────
async def consume(
db: AsyncSession,
*,
user_id: UUID | str,
points: int,
source_type: str,
source_id: str,
description: str = "",
duration: float | None = None,
) -> PointTransaction:
"""
直接扣费(后置计费)。
业务执行成功后调用,按实际消耗直接扣除余额。
余额不足时允许欠费(余额变负),但欠费后用户不可继续使用。
:param points: 实际消耗积分(正整数)
:param source_type: 消费来源类型
:param source_id: 关联的任务 ID 或订单 ID
:return: 消费流水记录
"""
if points <= 0:
raise ValueError("消费积分必须大于 0")
# 1. 获取用户积分账户(加锁)
result = await db.execute(
select(UserPoint)
.where(UserPoint.user_id == user_id)
.with_for_update()
)
up = result.scalar_one_or_none()
if not up:
# 没有积分账户也允许消费(形成欠费)
up = UserPoint(
user_id=user_id,
balance=0,
total_recharged=0,
total_consumed=0,
total_expired=0,
)
db.add(up)
await db.flush()
# 2. FIFO 扣减批次 remaining
result = await db.execute(
select(PointBatch)
.where(
PointBatch.user_id == user_id,
PointBatch.remaining > 0,
PointBatch.expired_at > _now(),
)
.order_by(PointBatch.expired_at.asc())
.with_for_update()
)
batches: list[PointBatch] = list(result.scalars().all())
remaining_to_deduct = points
for batch in batches:
if remaining_to_deduct <= 0:
break
deduct = min(batch.remaining, remaining_to_deduct)
batch.remaining -= deduct
remaining_to_deduct -= deduct
# 3. 更新用户账户(允许欠费:balance 可能变负)
balance_before = up.balance
up.balance -= points
up.total_consumed += points
# 4. 写消费流水
tx = PointTransaction(
user_id=user_id,
type="consume",
amount=points,
balance_before=balance_before,
balance_after=up.balance,
source_type=source_type,
source_id=source_id,
batch_id=batches[0].id if batches else None,
duration=duration,
description=description or f"消费 {source_type} {points} 积分",
)
db.add(tx)
return tx
# ── 过期回收 ──────────────────────────────────────────
async def expire_batches(db: AsyncSession) -> int:
"""
回收过期积分批次。返回过期积分总数。
这是一个批量维护操作,建议由定时任务调用。
使用 FOR UPDATE 锁定批次和用户积分账户,防止并发回收冲突。
"""
now = _now()
# 1. 获取过期批次(加锁)
result = await db.execute(
select(PointBatch)
.where(
PointBatch.expired_at <= now,
PointBatch.remaining > 0,
)
.with_for_update()
)
expired_batches: list[PointBatch] = list(result.scalars().all())
total_expired = 0
for batch in expired_batches:
recoverable = batch.remaining
if recoverable <= 0:
continue
# 获取用户积分账户(加锁)
up_result = await db.execute(
select(UserPoint)
.where(UserPoint.user_id == batch.user_id)
.with_for_update()
)
up = up_result.scalar_one_or_none()
if not up:
continue
balance_before = up.balance
up.balance -= recoverable
up.total_expired += recoverable
batch.remaining -= recoverable
total_expired += recoverable
# 写过期流水
tx = PointTransaction(
user_id=batch.user_id,
type="expire",
amount=recoverable,
balance_before=balance_before,
balance_after=up.balance,
source_type=batch.source,
source_id=None,
batch_id=batch.id,
description=f"积分批次过期回收 {recoverable} 积分",
)
db.add(tx)
return total_expired