Some checks failed
continuous-integration/drone/push Build is failing
1. 课程学习进度追踪
- 新增 UserCourseProgress 和 UserMaterialProgress 模型
- 新增 /api/v1/progress/* 进度追踪 API
- 更新 admin.py 使用真实课程完成率数据
2. 路由权限检查完善
- 新增前端 permissionChecker.ts 权限检查工具
- 更新 router/guard.ts 实现团队和课程权限验证
- 新增后端 permission_service.py
3. AI 陪练音频转文本
- 新增 speech_recognition.py 语音识别服务
- 新增 /api/v1/speech/* API
- 更新 ai-practice-coze.vue 支持语音输入
4. 双人对练报告生成
- 更新 practice_room_service.py 添加报告生成功能
- 新增 /rooms/{room_code}/report API
- 更新 duo-practice-report.vue 调用真实 API
5. 学习提醒推送
- 新增 notification_service.py 通知服务
- 新增 scheduler_service.py 定时任务服务
- 支持钉钉、企微、站内消息推送
6. 智能学习推荐
- 新增 recommendation_service.py 推荐服务
- 新增 /api/v1/recommendations/* API
- 支持错题、能力、进度、热门多维度推荐
7. 安全问题修复
- DEBUG 默认值改为 False
- 添加 SECRET_KEY 安全警告
- 新增 check_security_settings() 检查函数
8. 证书 PDF 生成
- 更新 certificate_service.py 添加 PDF 生成
- 添加 weasyprint、Pillow、qrcode 依赖
- 更新下载 API 支持 PDF 和 PNG 格式
589 lines
18 KiB
Python
589 lines
18 KiB
Python
"""
|
||
等级服务
|
||
|
||
提供用户等级管理功能:
|
||
- 经验值获取与计算
|
||
- 等级升级判断
|
||
- 每日签到
|
||
- 排行榜查询
|
||
"""
|
||
|
||
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
|
||
}
|