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

@@ -107,5 +107,8 @@ api_router.include_router(admin_portal_router, tags=["admin-portal"])
# system_settings_router 系统设置路由(企业管理员配置)
from .system_settings import router as system_settings_router
api_router.include_router(system_settings_router, prefix="/settings", tags=["system-settings"])
# level_router 等级与奖章路由
from .endpoints.level import router as level_router
api_router.include_router(level_router, prefix="/level", tags=["level"])
__all__ = ["api_router"]

View File

@@ -0,0 +1,277 @@
"""
等级与奖章 API
提供等级查询、奖章查询、排行榜、签到等接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
from app.models.user import User
router = APIRouter()
# ============================================
# 等级相关接口
# ============================================
@router.get("/me", response_model=ResponseModel)
async def get_my_level(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户等级信息
返回用户的等级、经验值、称号、连续登录天数等信息
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.get("/user/{user_id}", response_model=ResponseModel)
async def get_user_level(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取指定用户等级信息
Args:
user_id: 用户ID
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(user_id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.post("/checkin", response_model=ResponseModel)
async def daily_checkin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
每日签到
每天首次签到获得经验值,连续签到有额外奖励
"""
level_service = LevelService(db)
badge_service = BadgeService(db)
# 执行签到
checkin_result = await level_service.daily_checkin(current_user.id)
# 检查是否解锁新奖章
new_badges = []
if checkin_result["success"]:
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message=checkin_result["message"],
data={
**checkin_result,
"new_badges": new_badges
}
)
@router.get("/exp-history", response_model=ResponseModel)
async def get_exp_history(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取经验值变化历史
Args:
limit: 每页数量默认50最大100
offset: 偏移量
exp_type: 类型筛选exam/practice/training/task/login/badge/other
"""
level_service = LevelService(db)
history, total = await level_service.get_exp_history(
user_id=current_user.id,
limit=limit,
offset=offset,
exp_type=exp_type
)
return ResponseModel(
message="获取成功",
data={
"items": history,
"total": total,
"limit": limit,
"offset": offset
}
)
@router.get("/leaderboard", response_model=ResponseModel)
async def get_leaderboard(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级排行榜
Args:
limit: 每页数量默认50最大100
offset: 偏移量
"""
level_service = LevelService(db)
# 获取排行榜
leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset)
# 获取当前用户排名
my_rank = await level_service.get_user_rank(current_user.id)
# 获取当前用户等级信息
my_level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data={
"items": leaderboard,
"total": total,
"limit": limit,
"offset": offset,
"my_rank": my_rank,
"my_level_info": my_level_info
}
)
# ============================================
# 奖章相关接口
# ============================================
@router.get("/badges/all", response_model=ResponseModel)
async def get_all_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有奖章定义
返回所有可获得的奖章列表
"""
badge_service = BadgeService(db)
badges = await badge_service.get_all_badges()
return ResponseModel(
message="获取成功",
data=badges
)
@router.get("/badges/me", response_model=ResponseModel)
async def get_my_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的奖章(含解锁状态)
返回所有奖章及用户是否已解锁
"""
badge_service = BadgeService(db)
badges = await badge_service.get_user_badges_with_status(current_user.id)
# 统计已解锁数量
unlocked_count = sum(1 for b in badges if b["unlocked"])
return ResponseModel(
message="获取成功",
data={
"badges": badges,
"total": len(badges),
"unlocked_count": unlocked_count
}
)
@router.get("/badges/unnotified", response_model=ResponseModel)
async def get_unnotified_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取未通知的新奖章
用于前端显示新获得奖章的弹窗提示
"""
badge_service = BadgeService(db)
badges = await badge_service.get_unnotified_badges(current_user.id)
return ResponseModel(
message="获取成功",
data=badges
)
@router.post("/badges/mark-notified", response_model=ResponseModel)
async def mark_badges_notified(
badge_ids: Optional[list[int]] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
标记奖章为已通知
Args:
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
badge_service = BadgeService(db)
await badge_service.mark_badges_notified(current_user.id, badge_ids)
await db.commit()
return ResponseModel(
message="标记成功"
)
@router.post("/check-badges", response_model=ResponseModel)
async def check_and_award_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
检查并授予符合条件的奖章
手动触发奖章检查,返回新获得的奖章
"""
badge_service = BadgeService(db)
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message="检查完成",
data={
"new_badges": new_badges,
"count": len(new_badges)
}
)

View File

@@ -134,8 +134,44 @@ async def submit_exam(
user_agent=http_request.headers.get("user-agent")
)
)
return ResponseModel(code=200, data=SubmitExamResponse(**result), message="考试提交成功")
# 考试通过时触发经验值和奖章检查
exp_result = None
new_badges = []
if result.get("is_passed"):
try:
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
level_service = LevelService(db)
badge_service = BadgeService(db)
# 添加考试经验值
exp_result = await level_service.add_exam_exp(
user_id=current_user.id,
exam_id=request.exam_id,
score=result.get("total_score", 0),
is_passed=True
)
# 检查是否解锁新奖章
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
except Exception as e:
logger.warning(f"考试经验值/奖章处理失败: {str(e)}")
# 将经验值结果添加到返回数据
response_data = SubmitExamResponse(**result)
return ResponseModel(
code=200,
data={
**response_data.model_dump(),
"exp_result": exp_result,
"new_badges": new_badges
},
message="考试提交成功"
)
@router.get("/mistakes", response_model=ResponseModel[GetMistakesResponse])

View File

@@ -704,10 +704,37 @@ async def end_practice_session(
logger.info(f"结束陪练会话: session_id={session_id}, 时长={session.duration_seconds}秒, 轮次={session.turns}")
# 练习完成时触发经验值和奖章检查
exp_result = None
new_badges = []
try:
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
level_service = LevelService(db)
badge_service = BadgeService(db)
# 添加练习经验值
exp_result = await level_service.add_practice_exp(
user_id=current_user.id,
session_id=session.id
)
# 检查是否解锁新奖章
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
except Exception as e:
logger.warning(f"练习经验值/奖章处理失败: {str(e)}")
return ResponseModel(
code=200,
message="会话已结束",
data=session
data={
"session": session,
"exp_result": exp_result,
"new_badges": new_badges
}
)
except HTTPException:

View File

@@ -261,8 +261,40 @@ async def end_training(
)
logger.info(f"用户 {current_user['id']} 结束陪练会话: {session_id}")
# 陪练完成时触发经验值和奖章检查
exp_result = None
new_badges = []
try:
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
level_service = LevelService(db)
badge_service = BadgeService(db)
# 获取陪练得分(如果有报告的话)
score = response.get("total_score") if isinstance(response, dict) else None
# 添加陪练经验值
exp_result = await level_service.add_training_exp(
user_id=current_user["id"],
session_id=session_id,
score=score
)
# 检查是否解锁新奖章
new_badges = await badge_service.check_and_award_badges(current_user["id"])
await db.commit()
except Exception as e:
logger.warning(f"陪练经验值/奖章处理失败: {str(e)}")
# 将经验值结果添加到返回数据
result_data = response if isinstance(response, dict) else {"session_id": session_id}
result_data["exp_result"] = exp_result
result_data["new_badges"] = new_badges
return ResponseModel(data=response, message="结束陪练成功")
return ResponseModel(data=result_data, message="结束陪练成功")
except HTTPException:
raise

View File

@@ -17,6 +17,16 @@ from app.models.practice import PracticeScene, PracticeSession, PracticeDialogue
from app.models.system_log import SystemLog
from app.models.task import Task, TaskCourse, TaskAssignment
from app.models.notification import Notification
from app.models.level import (
UserLevel,
ExpHistory,
BadgeDefinition,
UserBadge,
LevelConfig,
ExpType,
BadgeCategory,
ConditionType,
)
__all__ = [
"Base",
@@ -46,4 +56,12 @@ __all__ = [
"TaskCourse",
"TaskAssignment",
"Notification",
"UserLevel",
"ExpHistory",
"BadgeDefinition",
"UserBadge",
"LevelConfig",
"ExpType",
"BadgeCategory",
"ConditionType",
]

153
backend/app/models/level.py Normal file
View File

@@ -0,0 +1,153 @@
"""
等级与奖章系统模型
包含:
- UserLevel: 用户等级信息
- ExpHistory: 经验值变化历史
- BadgeDefinition: 奖章定义
- UserBadge: 用户已获得的奖章
- LevelConfig: 等级配置
"""
from datetime import datetime, date
from typing import Optional, List
from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class UserLevel(BaseModel):
"""用户等级表"""
__tablename__ = "user_levels"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
level = Column(Integer, nullable=False, default=1, comment="当前等级")
exp = Column(Integer, nullable=False, default=0, comment="当前经验值")
total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值")
login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数")
max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数")
last_login_date = Column(Date, nullable=True, comment="最后登录日期")
last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间")
# 关联
user = relationship("User", backref="user_level", uselist=False)
# 不继承 is_deleted 等软删除字段
is_deleted = None
deleted_at = None
class ExpHistory(BaseModel):
"""经验值历史表"""
__tablename__ = "exp_history"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
exp_change = Column(Integer, nullable=False, comment="经验值变化")
exp_type = Column(String(50), nullable=False, index=True, comment="类型exam/practice/training/task/login/badge/other")
source_id = Column(Integer, nullable=True, comment="来源记录ID")
description = Column(String(255), nullable=False, comment="描述")
level_before = Column(Integer, nullable=True, comment="变化前等级")
level_after = Column(Integer, nullable=True, comment="变化后等级")
# 关联
user = relationship("User", backref="exp_histories")
# 不继承软删除字段
is_deleted = None
deleted_at = None
class BadgeDefinition(BaseModel):
"""奖章定义表"""
__tablename__ = "badge_definitions"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(50), nullable=False, unique=True, comment="奖章编码")
name = Column(String(100), nullable=False, comment="奖章名称")
description = Column(String(255), nullable=False, comment="奖章描述")
icon = Column(String(100), nullable=False, default="Medal", comment="图标名称")
category = Column(String(50), nullable=False, index=True, comment="分类")
condition_type = Column(String(50), nullable=False, comment="条件类型")
condition_field = Column(String(100), nullable=True, comment="条件字段")
condition_value = Column(Integer, nullable=False, default=1, comment="条件数值")
exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值")
sort_order = Column(Integer, nullable=False, default=0, comment="排序")
is_active = Column(Boolean, nullable=False, default=True, comment="是否启用")
# 关联
user_badges = relationship("UserBadge", back_populates="badge")
# 不继承软删除字段
is_deleted = None
deleted_at = None
class UserBadge(BaseModel):
"""用户奖章表"""
__tablename__ = "user_badges"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True)
unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间")
is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知")
notified_at = Column(DateTime, nullable=True, comment="通知时间")
# 关联
user = relationship("User", backref="badges")
badge = relationship("BadgeDefinition", back_populates="user_badges")
# 不继承软删除字段
is_deleted = None
deleted_at = None
class LevelConfig(BaseModel):
"""等级配置表"""
__tablename__ = "level_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
level = Column(Integer, nullable=False, unique=True, comment="等级")
exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值")
total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值")
title = Column(String(50), nullable=False, comment="等级称号")
color = Column(String(20), nullable=True, comment="等级颜色")
# 不继承软删除字段
is_deleted = None
deleted_at = None
# 经验值类型枚举
class ExpType:
"""经验值类型"""
EXAM = "exam" # 考试
PRACTICE = "practice" # 练习
TRAINING = "training" # 陪练
TASK = "task" # 任务
LOGIN = "login" # 登录/签到
BADGE = "badge" # 奖章奖励
OTHER = "other" # 其他
# 奖章分类枚举
class BadgeCategory:
"""奖章分类"""
LEARNING = "learning" # 学习进度
EXAM = "exam" # 考试成绩
PRACTICE = "practice" # 练习时长
STREAK = "streak" # 连续打卡
SPECIAL = "special" # 特殊成就
# 条件类型枚举
class ConditionType:
"""解锁条件类型"""
COUNT = "count" # 次数
SCORE = "score" # 分数
STREAK = "streak" # 连续天数
LEVEL = "level" # 等级
DURATION = "duration" # 时长

View File

@@ -0,0 +1,467 @@
"""
奖章服务
提供奖章管理功能:
- 获取奖章定义
- 检查奖章解锁条件
- 授予奖章
- 获取用户奖章
"""
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.level import (
BadgeDefinition, UserBadge, UserLevel, ExpHistory, ExpType,
BadgeCategory, ConditionType
)
from app.models.exam import Exam
from app.models.practice import PracticeSession, PracticeReport
from app.models.training import TrainingSession, TrainingReport
from app.models.task import TaskAssignment
logger = get_logger(__name__)
class BadgeService:
"""奖章服务"""
def __init__(self, db: AsyncSession):
self.db = db
self._badge_definitions: Optional[List[BadgeDefinition]] = None
async def _get_badge_definitions(self) -> List[BadgeDefinition]:
"""获取所有奖章定义(带缓存)"""
if self._badge_definitions is None:
result = await self.db.execute(
select(BadgeDefinition)
.where(BadgeDefinition.is_active == True)
.order_by(BadgeDefinition.sort_order)
)
self._badge_definitions = list(result.scalars().all())
return self._badge_definitions
async def get_all_badges(self) -> List[Dict[str, Any]]:
"""
获取所有奖章定义
Returns:
奖章定义列表
"""
badges = await self._get_badge_definitions()
return [
{
"id": b.id,
"code": b.code,
"name": b.name,
"description": b.description,
"icon": b.icon,
"category": b.category,
"condition_type": b.condition_type,
"condition_value": b.condition_value,
"exp_reward": b.exp_reward,
}
for b in badges
]
async def get_user_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
获取用户已解锁的奖章
Args:
user_id: 用户ID
Returns:
用户奖章列表
"""
result = await self.db.execute(
select(UserBadge, BadgeDefinition)
.join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id)
.where(UserBadge.user_id == user_id)
.order_by(UserBadge.unlocked_at.desc())
)
rows = result.all()
return [
{
"id": user_badge.id,
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"category": badge.category,
"unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None,
"is_notified": user_badge.is_notified,
}
for user_badge, badge in rows
]
async def get_user_badges_with_status(self, user_id: int) -> List[Dict[str, Any]]:
"""
获取所有奖章及用户解锁状态
Args:
user_id: 用户ID
Returns:
所有奖章列表(含解锁状态)
"""
# 获取所有奖章定义
all_badges = await self._get_badge_definitions()
# 获取用户已解锁的奖章
result = await self.db.execute(
select(UserBadge).where(UserBadge.user_id == user_id)
)
user_badges = {ub.badge_id: ub for ub in result.scalars().all()}
badges_with_status = []
for badge in all_badges:
user_badge = user_badges.get(badge.id)
badges_with_status.append({
"id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"category": badge.category,
"condition_type": badge.condition_type,
"condition_value": badge.condition_value,
"exp_reward": badge.exp_reward,
"unlocked": user_badge is not None,
"unlocked_at": user_badge.unlocked_at.isoformat() if user_badge else None,
})
return badges_with_status
async def _get_user_stats(self, user_id: int) -> Dict[str, Any]:
"""
获取用户统计数据(用于检查奖章条件)
Args:
user_id: 用户ID
Returns:
统计数据字典
"""
stats = {
"login_count": 0,
"login_streak": 0,
"course_completed": 0,
"exam_passed": 0,
"exam_perfect_count": 0,
"exam_excellent": 0,
"practice_count": 0,
"practice_hours": 0,
"training_count": 0,
"first_practice_90": 0,
"user_level": 1,
}
# 获取用户等级信息
result = await self.db.execute(
select(UserLevel).where(UserLevel.user_id == user_id)
)
user_level = result.scalar_one_or_none()
if user_level:
stats["login_streak"] = user_level.login_streak
stats["user_level"] = user_level.level
# 获取登录次数(从经验值历史)
result = await self.db.execute(
select(func.count(ExpHistory.id))
.where(
ExpHistory.user_id == user_id,
ExpHistory.exp_type == ExpType.LOGIN
)
)
stats["login_count"] = result.scalar() or 0
# 获取考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.sum(func.if_(Exam.score >= 100, 1, 0)),
func.sum(func.if_(Exam.score >= 90, 1, 0))
)
.where(
Exam.user_id == user_id,
Exam.is_passed == True,
Exam.status == "submitted"
)
)
row = result.first()
if row:
stats["exam_passed"] = row[0] or 0
stats["exam_perfect_count"] = int(row[1] or 0)
stats["exam_excellent"] = int(row[2] or 0)
# 获取练习统计
result = await self.db.execute(
select(
func.count(PracticeSession.id),
func.sum(PracticeSession.duration_seconds)
)
.where(
PracticeSession.user_id == user_id,
PracticeSession.status == "completed"
)
)
row = result.first()
if row:
stats["practice_count"] = row[0] or 0
total_seconds = row[1] or 0
stats["practice_hours"] = total_seconds / 3600
# 获取陪练统计
result = await self.db.execute(
select(func.count(TrainingSession.id))
.where(
TrainingSession.user_id == user_id,
TrainingSession.status == "COMPLETED"
)
)
stats["training_count"] = result.scalar() or 0
# 检查首次高分陪练
result = await self.db.execute(
select(func.count(TrainingReport.id))
.where(
TrainingReport.user_id == user_id,
TrainingReport.overall_score >= 90
)
)
stats["first_practice_90"] = 1 if (result.scalar() or 0) > 0 else 0
return stats
async def check_and_award_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
检查并授予用户符合条件的奖章
Args:
user_id: 用户ID
Returns:
新获得的奖章列表
"""
# 获取用户统计数据
stats = await self._get_user_stats(user_id)
# 获取所有奖章定义
all_badges = await self._get_badge_definitions()
# 获取用户已有的奖章
result = await self.db.execute(
select(UserBadge.badge_id).where(UserBadge.user_id == user_id)
)
owned_badge_ids = {row[0] for row in result.all()}
# 检查每个奖章的解锁条件
newly_awarded = []
for badge in all_badges:
if badge.id in owned_badge_ids:
continue
# 检查条件
condition_met = self._check_badge_condition(badge, stats)
if condition_met:
# 授予奖章
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
unlocked_at=datetime.now(),
is_notified=False
)
self.db.add(user_badge)
# 如果有经验奖励,添加经验值
if badge.exp_reward > 0:
from app.services.level_service import LevelService
level_service = LevelService(self.db)
await level_service.add_exp(
user_id=user_id,
exp_amount=badge.exp_reward,
exp_type=ExpType.BADGE,
description=f"解锁奖章「{badge.name}"
)
newly_awarded.append({
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"exp_reward": badge.exp_reward,
})
logger.info(f"用户 {user_id} 解锁奖章: {badge.name}")
if newly_awarded:
await self.db.flush()
return newly_awarded
def _check_badge_condition(self, badge: BadgeDefinition, stats: Dict[str, Any]) -> bool:
"""
检查奖章解锁条件
Args:
badge: 奖章定义
stats: 用户统计数据
Returns:
是否满足条件
"""
condition_field = badge.condition_field
condition_value = badge.condition_value
condition_type = badge.condition_type
if not condition_field:
return False
current_value = stats.get(condition_field, 0)
if condition_type == ConditionType.COUNT:
return current_value >= condition_value
elif condition_type == ConditionType.SCORE:
return current_value >= condition_value
elif condition_type == ConditionType.STREAK:
return current_value >= condition_value
elif condition_type == ConditionType.LEVEL:
return current_value >= condition_value
elif condition_type == ConditionType.DURATION:
return current_value >= condition_value
return False
async def award_badge(self, user_id: int, badge_code: str) -> Optional[Dict[str, Any]]:
"""
直接授予用户奖章(用于特殊奖章)
Args:
user_id: 用户ID
badge_code: 奖章编码
Returns:
奖章信息(如果成功)
"""
# 获取奖章定义
result = await self.db.execute(
select(BadgeDefinition).where(BadgeDefinition.code == badge_code)
)
badge = result.scalar_one_or_none()
if not badge:
logger.warning(f"奖章不存在: {badge_code}")
return None
# 检查是否已拥有
result = await self.db.execute(
select(UserBadge).where(
UserBadge.user_id == user_id,
UserBadge.badge_id == badge.id
)
)
if result.scalar_one_or_none():
logger.info(f"用户 {user_id} 已拥有奖章: {badge_code}")
return None
# 授予奖章
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
unlocked_at=datetime.now(),
is_notified=False
)
self.db.add(user_badge)
# 添加经验值奖励
if badge.exp_reward > 0:
from app.services.level_service import LevelService
level_service = LevelService(self.db)
await level_service.add_exp(
user_id=user_id,
exp_amount=badge.exp_reward,
exp_type=ExpType.BADGE,
description=f"解锁奖章「{badge.name}"
)
await self.db.flush()
logger.info(f"用户 {user_id} 获得奖章: {badge.name}")
return {
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"exp_reward": badge.exp_reward,
}
async def get_unnotified_badges(self, user_id: int) -> List[Dict[str, Any]]:
"""
获取用户未通知的新奖章
Args:
user_id: 用户ID
Returns:
未通知的奖章列表
"""
result = await self.db.execute(
select(UserBadge, BadgeDefinition)
.join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id)
.where(
UserBadge.user_id == user_id,
UserBadge.is_notified == False
)
.order_by(UserBadge.unlocked_at.desc())
)
rows = result.all()
return [
{
"user_badge_id": user_badge.id,
"badge_id": badge.id,
"code": badge.code,
"name": badge.name,
"description": badge.description,
"icon": badge.icon,
"exp_reward": badge.exp_reward,
"unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None,
}
for user_badge, badge in rows
]
async def mark_badges_notified(self, user_id: int, badge_ids: List[int] = None):
"""
标记奖章为已通知
Args:
user_id: 用户ID
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
from sqlalchemy import update
query = update(UserBadge).where(
UserBadge.user_id == user_id,
UserBadge.is_notified == False
)
if badge_ids:
query = query.where(UserBadge.badge_id.in_(badge_ids))
query = query.values(
is_notified=True,
notified_at=datetime.now()
)
await self.db.execute(query)
await self.db.flush()

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
}