feat: 新增等级与奖章系统
Some checks failed
continuous-integration/drone/push Build is failing

- 后端: 新增 user_levels, exp_history, badge_definitions, user_badges, level_configs 表
- 后端: 新增 LevelService 和 BadgeService 服务
- 后端: 新增等级/奖章/签到/排行榜 API 端点
- 后端: 考试/练习/陪练完成时触发经验值和奖章检查
- 前端: 新增 LevelBadge, ExpProgress, BadgeCard, LevelUpDialog 组件
- 前端: 新增排行榜页面
- 前端: 成长路径页面集成真实等级数据
- 数据库: 包含迁移脚本和初始数据
This commit is contained in:
yuliang_guo
2026-01-29 16:19:22 +08:00
parent 5dfe23831d
commit 0933b936f9
19 changed files with 3207 additions and 65 deletions

View File

@@ -0,0 +1,588 @@
"""
等级服务
提供用户等级管理功能:
- 经验值获取与计算
- 等级升级判断
- 每日签到
- 排行榜查询
"""
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
}