Files
012-kaopeilian/backend/app/services/level_service.py
yuliang_guo 0933b936f9
Some checks failed
continuous-integration/drone/push Build is failing
feat: 新增等级与奖章系统
- 后端: 新增 user_levels, exp_history, badge_definitions, user_badges, level_configs 表
- 后端: 新增 LevelService 和 BadgeService 服务
- 后端: 新增等级/奖章/签到/排行榜 API 端点
- 后端: 考试/练习/陪练完成时触发经验值和奖章检查
- 前端: 新增 LevelBadge, ExpProgress, BadgeCard, LevelUpDialog 组件
- 前端: 新增排行榜页面
- 前端: 成长路径页面集成真实等级数据
- 数据库: 包含迁移脚本和初始数据
2026-01-29 16:19:22 +08:00

589 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
等级服务
提供用户等级管理功能:
- 经验值获取与计算
- 等级升级判断
- 每日签到
- 排行榜查询
"""
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import select, func, and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.level import (
UserLevel, ExpHistory, LevelConfig, ExpType
)
from app.models.user import User
logger = get_logger(__name__)
# 经验值配置
EXP_CONFIG = {
# 每日签到
"login_base": 10, # 基础签到经验
"login_streak_7": 20, # 连续7天额外奖励
"login_streak_30": 50, # 连续30天额外奖励
# 考试
"exam_pass": 50, # 通过考试
"exam_excellent": 30, # 90分以上额外
"exam_perfect": 50, # 满分额外
# 练习
"practice_complete": 20, # 完成练习
"practice_good": 10, # 80分以上额外
# 陪练
"training_complete": 30, # 完成陪练
"training_good": 15, # 80分以上额外
# 任务
"task_complete": 40, # 完成任务
}
class LevelService:
"""等级服务"""
def __init__(self, db: AsyncSession):
self.db = db
self._level_configs: Optional[List[LevelConfig]] = None
async def _get_level_configs(self) -> List[LevelConfig]:
"""获取等级配置(带缓存)"""
if self._level_configs is None:
result = await self.db.execute(
select(LevelConfig).order_by(LevelConfig.level)
)
self._level_configs = list(result.scalars().all())
return self._level_configs
async def get_or_create_user_level(self, user_id: int) -> UserLevel:
"""
获取或创建用户等级记录
Args:
user_id: 用户ID
Returns:
UserLevel 对象
"""
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
user_level = result.scalar_one_or_none()
if not user_level:
user_level = UserLevel(
user_id=user_id,
level=1,
exp=0,
total_exp=0,
login_streak=0,
max_login_streak=0
)
self.db.add(user_level)
await self.db.flush()
logger.info(f"为用户 {user_id} 创建等级记录")
return user_level
async def get_user_level_info(self, user_id: int) -> Dict[str, Any]:
"""
获取用户等级详细信息
Args:
user_id: 用户ID
Returns:
包含等级、经验值、称号等信息的字典
"""
user_level = await self.get_or_create_user_level(user_id)
level_configs = await self._get_level_configs()
# 获取当前等级配置
current_config = next(
(c for c in level_configs if c.level == user_level.level),
level_configs[0] if level_configs else None
)
# 获取下一等级配置
next_config = next(
(c for c in level_configs if c.level == user_level.level + 1),
None
)
# 计算升级所需经验
exp_to_next_level = 0
next_level_total_exp = 0
if next_config:
next_level_total_exp = next_config.total_exp_required
exp_to_next_level = next_level_total_exp - user_level.total_exp
if exp_to_next_level < 0:
exp_to_next_level = 0
return {
"user_id": user_id,
"level": user_level.level,
"exp": user_level.exp,
"total_exp": user_level.total_exp,
"title": current_config.title if current_config else "初学者",
"color": current_config.color if current_config else "#909399",
"login_streak": user_level.login_streak,
"max_login_streak": user_level.max_login_streak,
"last_checkin_at": user_level.last_checkin_at.isoformat() if user_level.last_checkin_at else None,
"next_level_exp": next_level_total_exp,
"exp_to_next_level": exp_to_next_level,
"is_max_level": next_config is None,
}
async def add_exp(
self,
user_id: int,
exp_amount: int,
exp_type: str,
description: str,
source_id: Optional[int] = None
) -> Tuple[UserLevel, bool, Optional[int]]:
"""
增加用户经验值
Args:
user_id: 用户ID
exp_amount: 经验值数量
exp_type: 经验值类型
description: 描述
source_id: 来源ID可选
Returns:
(用户等级对象, 是否升级, 新等级)
"""
if exp_amount <= 0:
logger.warning(f"尝试增加非正数经验值: {exp_amount}")
return await self.get_or_create_user_level(user_id), False, None
user_level = await self.get_or_create_user_level(user_id)
level_before = user_level.level
# 增加经验值
user_level.exp += exp_amount
user_level.total_exp += exp_amount
# 检查是否升级
level_configs = await self._get_level_configs()
leveled_up = False
new_level = None
for config in level_configs:
if config.level > user_level.level and user_level.total_exp >= config.total_exp_required:
user_level.level = config.level
leveled_up = True
new_level = config.level
# 记录经验值历史
exp_history = ExpHistory(
user_id=user_id,
exp_change=exp_amount,
exp_type=exp_type,
source_id=source_id,
description=description,
level_before=level_before,
level_after=user_level.level
)
self.db.add(exp_history)
await self.db.flush()
if leveled_up:
logger.info(f"用户 {user_id} 升级: {level_before} -> {new_level}")
logger.info(f"用户 {user_id} 获得 {exp_amount} 经验值: {description}")
return user_level, leveled_up, new_level
async def daily_checkin(self, user_id: int) -> Dict[str, Any]:
"""
每日签到
Args:
user_id: 用户ID
Returns:
签到结果
"""
user_level = await self.get_or_create_user_level(user_id)
today = date.today()
# 检查今天是否已签到
if user_level.last_login_date == today:
return {
"success": False,
"message": "今天已经签到过了",
"exp_gained": 0,
"login_streak": user_level.login_streak,
"already_checked_in": True
}
# 计算连续登录
yesterday = today - timedelta(days=1)
if user_level.last_login_date == yesterday:
# 连续登录
user_level.login_streak += 1
else:
# 中断了,重新计算
user_level.login_streak = 1
# 更新最长连续登录记录
if user_level.login_streak > user_level.max_login_streak:
user_level.max_login_streak = user_level.login_streak
user_level.last_login_date = today
user_level.last_checkin_at = datetime.now()
# 计算签到经验
exp_gained = EXP_CONFIG["login_base"]
bonus_exp = 0
bonus_reason = []
# 连续登录奖励
if user_level.login_streak >= 30 and user_level.login_streak % 30 == 0:
bonus_exp += EXP_CONFIG["login_streak_30"]
bonus_reason.append(f"连续{user_level.login_streak}")
elif user_level.login_streak >= 7 and user_level.login_streak % 7 == 0:
bonus_exp += EXP_CONFIG["login_streak_7"]
bonus_reason.append(f"连续{user_level.login_streak}")
total_exp = exp_gained + bonus_exp
# 添加经验值
description = f"每日签到"
if bonus_reason:
description += f"{', '.join(bonus_reason)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=total_exp,
exp_type=ExpType.LOGIN,
description=description
)
await self.db.commit()
return {
"success": True,
"message": "签到成功",
"exp_gained": total_exp,
"base_exp": exp_gained,
"bonus_exp": bonus_exp,
"login_streak": user_level.login_streak,
"leveled_up": leveled_up,
"new_level": new_level,
"already_checked_in": False
}
async def get_exp_history(
self,
user_id: int,
limit: int = 50,
offset: int = 0,
exp_type: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""
获取经验值历史
Args:
user_id: 用户ID
limit: 限制数量
offset: 偏移量
exp_type: 类型筛选
Returns:
(历史记录列表, 总数)
"""
# 构建查询
query = select(ExpHistory).where(ExpHistory.user_id == user_id)
if exp_type:
query = query.where(ExpHistory.exp_type == exp_type)
# 获取总数
count_query = select(func.count(ExpHistory.id)).where(ExpHistory.user_id == user_id)
if exp_type:
count_query = count_query.where(ExpHistory.exp_type == exp_type)
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
# 获取记录
query = query.order_by(desc(ExpHistory.created_at)).limit(limit).offset(offset)
result = await self.db.execute(query)
records = result.scalars().all()
history = [
{
"id": r.id,
"exp_change": r.exp_change,
"exp_type": r.exp_type,
"description": r.description,
"level_before": r.level_before,
"level_after": r.level_after,
"created_at": r.created_at.isoformat() if r.created_at else None
}
for r in records
]
return history, total
async def get_leaderboard(
self,
limit: int = 50,
offset: int = 0
) -> Tuple[List[Dict[str, Any]], int]:
"""
获取等级排行榜
Args:
limit: 限制数量
offset: 偏移量
Returns:
(排行榜列表, 总数)
"""
# 获取总数
count_query = select(func.count(UserLevel.id))
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
# 获取排行榜(按等级和总经验值排序)
query = (
select(UserLevel, User)
.join(User, UserLevel.user_id == User.id)
.where(User.is_deleted == False)
.order_by(desc(UserLevel.level), desc(UserLevel.total_exp))
.limit(limit)
.offset(offset)
)
result = await self.db.execute(query)
rows = result.all()
# 获取等级配置
level_configs = await self._get_level_configs()
config_map = {c.level: c for c in level_configs}
leaderboard = []
for i, (user_level, user) in enumerate(rows):
config = config_map.get(user_level.level)
leaderboard.append({
"rank": offset + i + 1,
"user_id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"level": user_level.level,
"title": config.title if config else "初学者",
"color": config.color if config else "#909399",
"total_exp": user_level.total_exp,
"login_streak": user_level.login_streak,
})
return leaderboard, total
async def get_user_rank(self, user_id: int) -> Optional[int]:
"""
获取用户排名
Args:
user_id: 用户ID
Returns:
排名从1开始
"""
user_level = await self.get_or_create_user_level(user_id)
# 计算排在该用户前面的人数
query = select(func.count(UserLevel.id)).where(
and_(
UserLevel.user_id != user_id,
(
(UserLevel.level > user_level.level) |
(
(UserLevel.level == user_level.level) &
(UserLevel.total_exp > user_level.total_exp)
)
)
)
)
result = await self.db.execute(query)
count = result.scalar() or 0
return count + 1
async def add_exam_exp(
self,
user_id: int,
exam_id: int,
score: float,
is_passed: bool
) -> Optional[Dict[str, Any]]:
"""
考试通过获得经验值
Args:
user_id: 用户ID
exam_id: 考试ID
score: 得分
is_passed: 是否通过
Returns:
经验值变化信息
"""
if not is_passed:
return None
exp_gained = EXP_CONFIG["exam_pass"]
bonus = []
if score >= 100:
exp_gained += EXP_CONFIG["exam_perfect"]
bonus.append("满分")
elif score >= 90:
exp_gained += EXP_CONFIG["exam_excellent"]
bonus.append("优秀")
description = f"通过考试"
if bonus:
description += f"{', '.join(bonus)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.EXAM,
description=description,
source_id=exam_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}
async def add_practice_exp(
self,
user_id: int,
session_id: int,
score: Optional[float] = None
) -> Dict[str, Any]:
"""
完成练习获得经验值
Args:
user_id: 用户ID
session_id: 练习会话ID
score: 得分(可选)
Returns:
经验值变化信息
"""
exp_gained = EXP_CONFIG["practice_complete"]
bonus = []
if score is not None and score >= 80:
exp_gained += EXP_CONFIG["practice_good"]
bonus.append("高分")
description = f"完成练习"
if bonus:
description += f"{', '.join(bonus)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.PRACTICE,
description=description,
source_id=session_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}
async def add_training_exp(
self,
user_id: int,
session_id: int,
score: Optional[float] = None
) -> Dict[str, Any]:
"""
完成陪练获得经验值
Args:
user_id: 用户ID
session_id: 陪练会话ID
score: 得分(可选)
Returns:
经验值变化信息
"""
exp_gained = EXP_CONFIG["training_complete"]
bonus = []
if score is not None and score >= 80:
exp_gained += EXP_CONFIG["training_good"]
bonus.append("高分")
description = f"完成陪练"
if bonus:
description += f"{', '.join(bonus)}奖励)"
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.TRAINING,
description=description,
source_id=session_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}
async def add_task_exp(
self,
user_id: int,
task_id: int
) -> Dict[str, Any]:
"""
完成任务获得经验值
Args:
user_id: 用户ID
task_id: 任务ID
Returns:
经验值变化信息
"""
exp_gained = EXP_CONFIG["task_complete"]
_, leveled_up, new_level = await self.add_exp(
user_id=user_id,
exp_amount=exp_gained,
exp_type=ExpType.TASK,
description="完成任务",
source_id=task_id
)
return {
"exp_gained": exp_gained,
"leveled_up": leveled_up,
"new_level": new_level
}