From 0933b936f9bdbc5234a8f1088972b207801d77ec Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Thu, 29 Jan 2026 16:19:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=AD=89=E7=BA=A7?= =?UTF-8?q?=E4=B8=8E=E5=A5=96=E7=AB=A0=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 新增 user_levels, exp_history, badge_definitions, user_badges, level_configs 表 - 后端: 新增 LevelService 和 BadgeService 服务 - 后端: 新增等级/奖章/签到/排行榜 API 端点 - 后端: 考试/练习/陪练完成时触发经验值和奖章检查 - 前端: 新增 LevelBadge, ExpProgress, BadgeCard, LevelUpDialog 组件 - 前端: 新增排行榜页面 - 前端: 成长路径页面集成真实等级数据 - 数据库: 包含迁移脚本和初始数据 --- backend/app/api/v1/__init__.py | 3 + backend/app/api/v1/endpoints/level.py | 277 +++++++++ backend/app/api/v1/exam.py | 40 +- backend/app/api/v1/practice.py | 29 +- backend/app/api/v1/training.py | 34 +- backend/app/models/__init__.py | 18 + backend/app/models/level.py | 153 +++++ backend/app/services/badge_service.py | 467 ++++++++++++++ backend/app/services/level_service.py | 588 ++++++++++++++++++ backend/migrations/README.md | 112 ++-- backend/migrations/add_level_badge_system.sql | 192 ++++++ frontend/src/api/level.ts | 182 ++++++ frontend/src/components/BadgeCard.vue | 174 ++++++ frontend/src/components/ExpProgress.vue | 100 +++ frontend/src/components/LevelBadge.vue | 85 +++ frontend/src/components/LevelUpDialog.vue | 297 +++++++++ frontend/src/router/index.ts | 6 + frontend/src/views/trainee/growth-path.vue | 24 +- frontend/src/views/trainee/leaderboard.vue | 491 +++++++++++++++ 19 files changed, 3207 insertions(+), 65 deletions(-) create mode 100644 backend/app/api/v1/endpoints/level.py create mode 100644 backend/app/models/level.py create mode 100644 backend/app/services/badge_service.py create mode 100644 backend/app/services/level_service.py create mode 100644 backend/migrations/add_level_badge_system.sql create mode 100644 frontend/src/api/level.ts create mode 100644 frontend/src/components/BadgeCard.vue create mode 100644 frontend/src/components/ExpProgress.vue create mode 100644 frontend/src/components/LevelBadge.vue create mode 100644 frontend/src/components/LevelUpDialog.vue create mode 100644 frontend/src/views/trainee/leaderboard.vue diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 62aa263..c33087e 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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"] diff --git a/backend/app/api/v1/endpoints/level.py b/backend/app/api/v1/endpoints/level.py new file mode 100644 index 0000000..f00a499 --- /dev/null +++ b/backend/app/api/v1/endpoints/level.py @@ -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) + } + ) diff --git a/backend/app/api/v1/exam.py b/backend/app/api/v1/exam.py index 55ecba3..8bea421 100644 --- a/backend/app/api/v1/exam.py +++ b/backend/app/api/v1/exam.py @@ -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]) diff --git a/backend/app/api/v1/practice.py b/backend/app/api/v1/practice.py index b89d737..a8887f2 100644 --- a/backend/app/api/v1/practice.py +++ b/backend/app/api/v1/practice.py @@ -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: diff --git a/backend/app/api/v1/training.py b/backend/app/api/v1/training.py index a61d51b..22c2936 100644 --- a/backend/app/api/v1/training.py +++ b/backend/app/api/v1/training.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c802572..1a730f1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/level.py b/backend/app/models/level.py new file mode 100644 index 0000000..e8ab023 --- /dev/null +++ b/backend/app/models/level.py @@ -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" # 时长 diff --git a/backend/app/services/badge_service.py b/backend/app/services/badge_service.py new file mode 100644 index 0000000..0c99ac4 --- /dev/null +++ b/backend/app/services/badge_service.py @@ -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() diff --git a/backend/app/services/level_service.py b/backend/app/services/level_service.py new file mode 100644 index 0000000..e446880 --- /dev/null +++ b/backend/app/services/level_service.py @@ -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 + } diff --git a/backend/migrations/README.md b/backend/migrations/README.md index 48b15b0..2471627 100644 --- a/backend/migrations/README.md +++ b/backend/migrations/README.md @@ -1,82 +1,82 @@ # 数据库迁移说明 -## 目录结构 - -``` -migrations/ -├── README.md # 本说明文件 -└── versions/ - └── add_practice_rooms_table.sql # 双人对练功能迁移脚本 -``` +本目录包含 KPL 考培练系统的数据库迁移脚本。 ## 迁移脚本列表 -### 1. add_practice_rooms_table.sql +| 脚本 | 说明 | 创建时间 | +|------|------|----------| +| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 | -**功能**:双人对练功能数据库结构 +## 执行迁移 -**新增表**: -- `practice_rooms` - 对练房间表 -- `practice_room_messages` - 房间实时消息表 +### 测试环境(Docker) -**修改表**: -- `practice_dialogues` - 新增 `user_id`, `role_name`, `room_id`, `message_type` 字段 -- `practice_sessions` - 新增 `room_id`, `participant_role`, `session_type` 字段 -- `practice_reports` - 新增 `room_id`, `user_id`, `report_type`, `partner_feedback`, `interaction_score` 字段 - -## 执行方法 - -### 方法一:直接登录 MySQL 执行 +KPL 测试环境数据库在服务器 Docker 容器中运行: ```bash -# 1. 登录 MySQL -docker exec -it kpl-mysql-dev mysql -uroot -p +# 1. SSH 登录 KPL 服务器 +ssh root@ -# 2. 选择数据库 -USE kaopeilian; +# 2. 进入项目目录 +cd /www/wwwroot/kpl.ireborn.com.cn -# 3. 复制并执行 SQL 脚本内容 -# 或使用 source 命令执行脚本文件 +# 3. 执行迁移(方法一:直接执行) +docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql + +# 或者(方法二:交互式执行) +docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev +# 然后复制粘贴 SQL 脚本内容执行 + +# 方法三:从本地执行(需要先上传SQL文件到服务器) +# scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/ +# ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql" ``` -### 方法二:使用 docker exec 执行 +**注意**:MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认) + +### 生产环境 + +生产环境迁移前请确保: +1. 已备份数据库 +2. 在低峰期执行 +3. 测试环境验证通过 ```bash -# 1. 将 SQL 文件复制到容器 -docker cp migrations/versions/add_practice_rooms_table.sql kpl-mysql-dev:/tmp/ - -# 2. 执行迁移 -docker exec -i kpl-mysql-dev mysql -uroot -pYourPassword kaopeilian < /tmp/add_practice_rooms_table.sql -``` - -### 方法三:远程 SSH 执行 - -```bash -# SSH 到服务器后执行 -ssh user@your-server "docker exec -i kpl-mysql-dev mysql -uroot -pYourPassword kaopeilian" < migrations/versions/add_practice_rooms_table.sql +# 执行迁移(替换为实际的生产数据库配置) +mysql -h -u -p < backend/migrations/add_level_badge_system.sql ``` ## 回滚方法 -每个迁移脚本底部都包含了回滚 SQL。如需回滚,取消注释并执行回滚部分的 SQL 即可。 +如需回滚,执行以下 SQL: -## 生产环境迁移检查清单 +```sql +DROP TABLE IF EXISTS user_badges; +DROP TABLE IF EXISTS badge_definitions; +DROP TABLE IF EXISTS exp_history; +DROP TABLE IF EXISTS level_configs; +DROP TABLE IF EXISTS user_levels; +``` -- [ ] 备份数据库 -- [ ] 在测试环境验证迁移脚本 -- [ ] 检查是否有正在进行的事务 -- [ ] 执行迁移脚本 -- [ ] 验证表结构是否正确 -- [ ] 验证索引是否创建成功 -- [ ] 重启后端服务(如有必要) -- [ ] 验证功能是否正常 +## 验证迁移 -## 注意事项 +执行以下查询验证表是否创建成功: -1. **MySQL 版本兼容性**:脚本使用了 `IF NOT EXISTS` 和 `IF EXISTS`,确保 MySQL 版本支持这些语法(8.0+ 完全支持) +```sql +SHOW TABLES LIKE '%level%'; +SHOW TABLES LIKE '%badge%'; +SHOW TABLES LIKE '%exp%'; -2. **字符集**:表默认使用 `utf8mb4` 字符集,支持表情符号等特殊字符 +-- 查看表结构 +DESCRIBE user_levels; +DESCRIBE exp_history; +DESCRIBE badge_definitions; +DESCRIBE user_badges; +DESCRIBE level_configs; -3. **外键约束**:脚本中的外键约束默认被注释,根据实际需求决定是否启用 - -4. **索引优化**:已为常用查询字段创建索引,如需调整请根据实际查询模式优化 +-- 验证初始数据 +SELECT COUNT(*) FROM level_configs; -- 应该是 10 条 +SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条 +SELECT COUNT(*) FROM user_levels; -- 应该等于用户数 +``` diff --git a/backend/migrations/add_level_badge_system.sql b/backend/migrations/add_level_badge_system.sql new file mode 100644 index 0000000..29323be --- /dev/null +++ b/backend/migrations/add_level_badge_system.sql @@ -0,0 +1,192 @@ +-- ===================================================== +-- 等级与奖章系统数据库迁移脚本 +-- 版本: 1.0.0 +-- 创建时间: 2026-01-29 +-- 说明: 添加用户等级系统和奖章系统相关表 +-- ===================================================== + +-- 使用事务确保原子性 +START TRANSACTION; + +-- ===================================================== +-- 1. 用户等级表 (user_levels) +-- 存储用户的等级和经验值信息 +-- ===================================================== +CREATE TABLE IF NOT EXISTS user_levels ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '用户ID', + level INT NOT NULL DEFAULT 1 COMMENT '当前等级', + exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值', + total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值', + login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数', + max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数', + last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)', + last_checkin_at DATETIME NULL COMMENT '最后签到时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_id (user_id), + INDEX idx_level (level), + INDEX idx_total_exp (total_exp), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表'; + +-- ===================================================== +-- 2. 经验值历史表 (exp_history) +-- 记录每次经验值变化的详细信息 +-- ===================================================== +CREATE TABLE IF NOT EXISTS exp_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '用户ID', + exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)', + exp_type VARCHAR(50) NOT NULL COMMENT '类型:exam/practice/training/task/login/badge/other', + source_id INT NULL COMMENT '来源记录ID(如考试ID、练习ID等)', + description VARCHAR(255) NOT NULL COMMENT '描述', + level_before INT NULL COMMENT '变化前等级', + level_after INT NULL COMMENT '变化后等级', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id), + INDEX idx_exp_type (exp_type), + INDEX idx_created_at (created_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表'; + +-- ===================================================== +-- 3. 奖章定义表 (badge_definitions) +-- 定义所有可获得的奖章及其解锁条件 +-- ===================================================== +CREATE TABLE IF NOT EXISTS badge_definitions ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)', + name VARCHAR(100) NOT NULL COMMENT '奖章名称', + description VARCHAR(255) NOT NULL COMMENT '奖章描述', + icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称(Element Plus 图标)', + category VARCHAR(50) NOT NULL COMMENT '分类:learning/exam/practice/streak/special', + condition_type VARCHAR(50) NOT NULL COMMENT '条件类型:count/score/streak/level/duration', + condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)', + condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值', + exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_code (code), + INDEX idx_category (category), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表'; + +-- ===================================================== +-- 4. 用户奖章表 (user_badges) +-- 记录用户已解锁的奖章 +-- ===================================================== +CREATE TABLE IF NOT EXISTS user_badges ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '用户ID', + badge_id INT NOT NULL COMMENT '奖章ID', + unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间', + is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户', + notified_at DATETIME NULL COMMENT '通知时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_badge (user_id, badge_id), + INDEX idx_user_id (user_id), + INDEX idx_badge_id (badge_id), + INDEX idx_unlocked_at (unlocked_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表'; + +-- ===================================================== +-- 5. 等级配置表 (level_configs) +-- 定义每个等级所需的经验值和称号 +-- ===================================================== +CREATE TABLE IF NOT EXISTS level_configs ( + id INT AUTO_INCREMENT PRIMARY KEY, + level INT NOT NULL COMMENT '等级', + exp_required INT NOT NULL COMMENT '升到此级所需经验值', + total_exp_required INT NOT NULL COMMENT '累计所需经验值', + title VARCHAR(50) NOT NULL COMMENT '等级称号', + color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_level (level) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表'; + +-- ===================================================== +-- 6. 插入等级配置数据 +-- ===================================================== +INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES +(1, 0, 0, '初学者', '#909399'), +(2, 100, 100, '入门学徒', '#67C23A'), +(3, 200, 300, '勤奋学员', '#67C23A'), +(4, 400, 700, '进阶学员', '#409EFF'), +(5, 600, 1300, '优秀学员', '#409EFF'), +(6, 1000, 2300, '精英学员', '#E6A23C'), +(7, 1500, 3800, '资深学员', '#E6A23C'), +(8, 2000, 5800, '学习达人', '#F56C6C'), +(9, 3000, 8800, '学霸', '#F56C6C'), +(10, 5000, 13800, '大师', '#9B59B6'); + +-- ===================================================== +-- 7. 插入奖章定义数据 +-- ===================================================== + +-- 7.1 学习进度类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101), +('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102), +('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103), +('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104); + +-- 7.2 考试成绩类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201), +('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202), +('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203), +('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204), +('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205); + +-- 7.3 练习时长类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301), +('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302), +('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303), +('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304); + +-- 7.4 连续打卡类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401), +('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402), +('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403), +('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404); + +-- 7.5 特殊成就类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501), +('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502), +('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503), +('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504); + +-- ===================================================== +-- 8. 为现有用户初始化等级数据 +-- ===================================================== +INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date) +SELECT + id as user_id, + 1 as level, + 0 as exp, + 0 as total_exp, + 0 as login_streak, + NULL as last_login_date +FROM users +WHERE is_deleted = 0 +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +-- 提交事务 +COMMIT; + +-- ===================================================== +-- 回滚脚本(如需回滚,执行以下语句) +-- ===================================================== +-- DROP TABLE IF EXISTS user_badges; +-- DROP TABLE IF EXISTS badge_definitions; +-- DROP TABLE IF EXISTS exp_history; +-- DROP TABLE IF EXISTS level_configs; +-- DROP TABLE IF EXISTS user_levels; diff --git a/frontend/src/api/level.ts b/frontend/src/api/level.ts new file mode 100644 index 0000000..b97a04f --- /dev/null +++ b/frontend/src/api/level.ts @@ -0,0 +1,182 @@ +/** + * 等级与奖章 API + */ + +import request from '@/api/request' + +// 类型定义 +export interface LevelInfo { + user_id: number + level: number + exp: number + total_exp: number + title: string + color: string + login_streak: number + max_login_streak: number + last_checkin_at: string | null + next_level_exp: number + exp_to_next_level: number + is_max_level: boolean +} + +export interface ExpHistoryItem { + id: number + exp_change: number + exp_type: string + description: string + level_before: number | null + level_after: number | null + created_at: string +} + +export interface LeaderboardItem { + rank: number + user_id: number + username: string + full_name: string | null + avatar_url: string | null + level: number + title: string + color: string + total_exp: number + login_streak: number +} + +export interface Badge { + id: number + code: string + name: string + description: string + icon: string + category: string + condition_type?: string + condition_value?: number + exp_reward: number + unlocked?: boolean + unlocked_at?: string | null +} + +export interface CheckinResult { + success: boolean + message: string + exp_gained: number + base_exp?: number + bonus_exp?: number + login_streak: number + leveled_up?: boolean + new_level?: number | null + already_checked_in?: boolean + new_badges?: Badge[] +} + +// API 函数 + +/** + * 获取当前用户等级信息 + */ +export function getMyLevel() { + return request.get('/level/me') +} + +/** + * 获取指定用户等级信息 + */ +export function getUserLevel(userId: number) { + return request.get(`/level/user/${userId}`) +} + +/** + * 每日签到 + */ +export function dailyCheckin() { + return request.post('/level/checkin') +} + +/** + * 获取经验值历史 + */ +export function getExpHistory(params?: { + limit?: number + offset?: number + exp_type?: string +}) { + return request.get<{ + items: ExpHistoryItem[] + total: number + limit: number + offset: number + }>('/level/exp-history', { params }) +} + +/** + * 获取等级排行榜 + */ +export function getLeaderboard(params?: { + limit?: number + offset?: number +}) { + return request.get<{ + items: LeaderboardItem[] + total: number + limit: number + offset: number + my_rank: number + my_level_info: LevelInfo + }>('/level/leaderboard', { params }) +} + +/** + * 获取所有奖章定义 + */ +export function getAllBadges() { + return request.get('/level/badges/all') +} + +/** + * 获取用户奖章(含解锁状态) + */ +export function getMyBadges() { + return request.get<{ + badges: Badge[] + total: number + unlocked_count: number + }>('/level/badges/me') +} + +/** + * 获取未通知的新奖章 + */ +export function getUnnotifiedBadges() { + return request.get('/level/badges/unnotified') +} + +/** + * 标记奖章为已通知 + */ +export function markBadgesNotified(badgeIds?: number[]) { + return request.post('/level/badges/mark-notified', badgeIds) +} + +/** + * 手动检查并授予奖章 + */ +export function checkAndAwardBadges() { + return request.post<{ + new_badges: Badge[] + count: number + }>('/level/check-badges') +} + +export default { + getMyLevel, + getUserLevel, + dailyCheckin, + getExpHistory, + getLeaderboard, + getAllBadges, + getMyBadges, + getUnnotifiedBadges, + markBadgesNotified, + checkAndAwardBadges +} diff --git a/frontend/src/components/BadgeCard.vue b/frontend/src/components/BadgeCard.vue new file mode 100644 index 0000000..de9d96f --- /dev/null +++ b/frontend/src/components/BadgeCard.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/frontend/src/components/ExpProgress.vue b/frontend/src/components/ExpProgress.vue new file mode 100644 index 0000000..ceaccc6 --- /dev/null +++ b/frontend/src/components/ExpProgress.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/src/components/LevelBadge.vue b/frontend/src/components/LevelBadge.vue new file mode 100644 index 0000000..f915108 --- /dev/null +++ b/frontend/src/components/LevelBadge.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/components/LevelUpDialog.vue b/frontend/src/components/LevelUpDialog.vue new file mode 100644 index 0000000..a351712 --- /dev/null +++ b/frontend/src/components/LevelUpDialog.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index aae73af..7179a6f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -31,6 +31,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/trainee/growth-path.vue'), meta: { title: '我的成长路径', icon: 'TrendCharts' } }, + { + path: 'leaderboard', + name: 'Leaderboard', + component: () => import('@/views/trainee/leaderboard.vue'), + meta: { title: '等级排行榜', icon: 'Trophy' } + }, { path: 'course-center', name: 'CourseCenter', diff --git a/frontend/src/views/trainee/growth-path.vue b/frontend/src/views/trainee/growth-path.vue index 196ac94..1763466 100644 --- a/frontend/src/views/trainee/growth-path.vue +++ b/frontend/src/views/trainee/growth-path.vue @@ -877,12 +877,26 @@ const fetchUserInfo = async () => { userInfo.value = { name: user.full_name || user.username || '未命名', position: user.position_name || (user.role === 'admin' ? '管理员' : user.role === 'trainer' ? '培训师' : '学员'), - level: 1, // TODO: 从用户统计数据获取 - exp: 0, // TODO: 从用户统计数据获取 - nextLevelExp: 1000, // TODO: 从用户统计数据获取 + level: 1, + exp: 0, + nextLevelExp: 1000, avatar: user.avatar_url || '', - role: user.role || 'trainee', // 保存用户角色 - phone: user.phone || '' // 保存用户手机号 + role: user.role || 'trainee', + phone: user.phone || '' + } + + // 获取等级信息 + try { + const { getMyLevel } = await import('@/api/level') + const levelResponse = await getMyLevel() + if (levelResponse.code === 200 && levelResponse.data) { + const levelData = levelResponse.data + userInfo.value.level = levelData.level + userInfo.value.exp = levelData.total_exp + userInfo.value.nextLevelExp = levelData.next_level_exp || 1000 + } + } catch (levelError) { + console.warn('获取等级信息失败:', levelError) } } } catch (error) { diff --git a/frontend/src/views/trainee/leaderboard.vue b/frontend/src/views/trainee/leaderboard.vue new file mode 100644 index 0000000..a4d5e65 --- /dev/null +++ b/frontend/src/views/trainee/leaderboard.vue @@ -0,0 +1,491 @@ + + + + +