feat: 实现 KPL 系统功能改进计划
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 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

View File

@@ -1,323 +1,323 @@
"""
双人对练分析服务
功能:
- 分析双人对练对话
- 生成双方评估报告
- 对话标注和建议
"""
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from app.services.ai.ai_service import AIService
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
logger = logging.getLogger(__name__)
@dataclass
class UserEvaluation:
"""用户评估结果"""
user_name: str
role_name: str
total_score: int
dimensions: Dict[str, Dict[str, Any]]
highlights: List[str]
improvements: List[Dict[str, str]]
@dataclass
class DuoPracticeAnalysisResult:
"""双人对练分析结果"""
# 整体评估
interaction_quality: int = 0
scene_restoration: int = 0
overall_comment: str = ""
# 用户A评估
user_a_evaluation: Optional[UserEvaluation] = None
# 用户B评估
user_b_evaluation: Optional[UserEvaluation] = None
# 对话标注
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
# AI 元数据
raw_response: str = ""
ai_provider: str = ""
ai_model: str = ""
ai_latency_ms: int = 0
class DuoPracticeAnalysisService:
"""
双人对练分析服务
使用示例:
```python
service = DuoPracticeAnalysisService()
result = await service.analyze(
scene_name="销售场景",
scene_background="客户咨询产品",
role_a_name="销售顾问",
role_b_name="顾客",
user_a_name="张三",
user_b_name="李四",
dialogue_history=dialogue_list,
duration_seconds=300,
total_turns=20
)
```
"""
MODULE_CODE = "duo_practice_analysis"
async def analyze(
self,
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""
分析双人对练
Args:
scene_name: 场景名称
scene_background: 场景背景
role_a_name: 角色A名称
role_b_name: 角色B名称
role_a_description: 角色A描述
role_b_description: 角色B描述
user_a_name: 用户A名称
user_b_name: 用户B名称
dialogue_history: 对话历史列表
duration_seconds: 对练时长(秒)
total_turns: 总对话轮次
db: 数据库会话
Returns:
DuoPracticeAnalysisResult: 分析结果
"""
try:
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
# 格式化对话历史
dialogue_text = self._format_dialogue_history(dialogue_history)
# 创建 AI 服务
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
# 构建用户提示词
user_prompt = USER_PROMPT.format(
scene_name=scene_name,
scene_background=scene_background or "未设置",
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description or f"扮演{role_a_name}角色",
role_b_description=role_b_description or f"扮演{role_b_name}角色",
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_text,
duration_seconds=duration_seconds,
total_turns=total_turns
)
# 调用 AI
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
ai_response = await ai_service.chat(
messages=messages,
model="gemini-3-flash-preview", # 使用快速模型
temperature=0.3,
prompt_name="duo_practice_analysis"
)
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
# 解析 AI 输出
result = self._parse_analysis_result(
ai_response.content,
user_a_name=user_a_name,
user_b_name=user_b_name,
role_a_name=role_a_name,
role_b_name=role_b_name
)
# 填充 AI 元数据
result.raw_response = ai_response.content
result.ai_provider = ai_response.provider
result.ai_model = ai_response.model
result.ai_latency_ms = ai_response.latency_ms
return result
except Exception as e:
logger.error(f"双人对练分析失败: {e}", exc_info=True)
# 返回空结果
return DuoPracticeAnalysisResult(
overall_comment=f"分析失败: {str(e)}"
)
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
"""格式化对话历史"""
lines = []
for d in dialogues:
speaker = d.get("role_name") or d.get("speaker", "未知")
content = d.get("content", "")
seq = d.get("sequence", 0)
lines.append(f"[{seq}] {speaker}{content}")
return "\n".join(lines)
def _parse_analysis_result(
self,
ai_output: str,
user_a_name: str,
user_b_name: str,
role_a_name: str,
role_b_name: str
) -> DuoPracticeAnalysisResult:
"""解析 AI 输出"""
result = DuoPracticeAnalysisResult()
try:
# 尝试提取 JSON
json_str = ai_output
# 如果输出包含 markdown 代码块,提取其中的 JSON
if "```json" in ai_output:
start = ai_output.find("```json") + 7
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
elif "```" in ai_output:
start = ai_output.find("```") + 3
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
data = json.loads(json_str)
# 解析整体评估
overall = data.get("overall_evaluation", {})
result.interaction_quality = overall.get("interaction_quality", 0)
result.scene_restoration = overall.get("scene_restoration", 0)
result.overall_comment = overall.get("overall_comment", "")
# 解析用户A评估
user_a_data = data.get("user_a_evaluation", {})
if user_a_data:
result.user_a_evaluation = UserEvaluation(
user_name=user_a_data.get("user_name", user_a_name),
role_name=user_a_data.get("role_name", role_a_name),
total_score=user_a_data.get("total_score", 0),
dimensions=user_a_data.get("dimensions", {}),
highlights=user_a_data.get("highlights", []),
improvements=user_a_data.get("improvements", [])
)
# 解析用户B评估
user_b_data = data.get("user_b_evaluation", {})
if user_b_data:
result.user_b_evaluation = UserEvaluation(
user_name=user_b_data.get("user_name", user_b_name),
role_name=user_b_data.get("role_name", role_b_name),
total_score=user_b_data.get("total_score", 0),
dimensions=user_b_data.get("dimensions", {}),
highlights=user_b_data.get("highlights", []),
improvements=user_b_data.get("improvements", [])
)
# 解析对话标注
result.dialogue_annotations = data.get("dialogue_annotations", [])
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e}")
result.overall_comment = "AI 输出格式异常,请重试"
except Exception as e:
logger.error(f"解析分析结果失败: {e}")
result.overall_comment = f"解析失败: {str(e)}"
return result
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
"""将结果转换为字典(用于 API 响应)"""
return {
"overall_evaluation": {
"interaction_quality": result.interaction_quality,
"scene_restoration": result.scene_restoration,
"overall_comment": result.overall_comment
},
"user_a_evaluation": {
"user_name": result.user_a_evaluation.user_name,
"role_name": result.user_a_evaluation.role_name,
"total_score": result.user_a_evaluation.total_score,
"dimensions": result.user_a_evaluation.dimensions,
"highlights": result.user_a_evaluation.highlights,
"improvements": result.user_a_evaluation.improvements
} if result.user_a_evaluation else None,
"user_b_evaluation": {
"user_name": result.user_b_evaluation.user_name,
"role_name": result.user_b_evaluation.role_name,
"total_score": result.user_b_evaluation.total_score,
"dimensions": result.user_b_evaluation.dimensions,
"highlights": result.user_b_evaluation.highlights,
"improvements": result.user_b_evaluation.improvements
} if result.user_b_evaluation else None,
"dialogue_annotations": result.dialogue_annotations,
"ai_metadata": {
"provider": result.ai_provider,
"model": result.ai_model,
"latency_ms": result.ai_latency_ms
}
}
# ==================== 全局实例 ====================
duo_practice_analysis_service = DuoPracticeAnalysisService()
# ==================== 便捷函数 ====================
async def analyze_duo_practice(
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""便捷函数:分析双人对练"""
return await duo_practice_analysis_service.analyze(
scene_name=scene_name,
scene_background=scene_background,
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description,
role_b_description=role_b_description,
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_history,
duration_seconds=duration_seconds,
total_turns=total_turns,
db=db
)
"""
双人对练分析服务
功能:
- 分析双人对练对话
- 生成双方评估报告
- 对话标注和建议
"""
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from app.services.ai.ai_service import AIService
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
logger = logging.getLogger(__name__)
@dataclass
class UserEvaluation:
"""用户评估结果"""
user_name: str
role_name: str
total_score: int
dimensions: Dict[str, Dict[str, Any]]
highlights: List[str]
improvements: List[Dict[str, str]]
@dataclass
class DuoPracticeAnalysisResult:
"""双人对练分析结果"""
# 整体评估
interaction_quality: int = 0
scene_restoration: int = 0
overall_comment: str = ""
# 用户A评估
user_a_evaluation: Optional[UserEvaluation] = None
# 用户B评估
user_b_evaluation: Optional[UserEvaluation] = None
# 对话标注
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
# AI 元数据
raw_response: str = ""
ai_provider: str = ""
ai_model: str = ""
ai_latency_ms: int = 0
class DuoPracticeAnalysisService:
"""
双人对练分析服务
使用示例:
```python
service = DuoPracticeAnalysisService()
result = await service.analyze(
scene_name="销售场景",
scene_background="客户咨询产品",
role_a_name="销售顾问",
role_b_name="顾客",
user_a_name="张三",
user_b_name="李四",
dialogue_history=dialogue_list,
duration_seconds=300,
total_turns=20
)
```
"""
MODULE_CODE = "duo_practice_analysis"
async def analyze(
self,
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""
分析双人对练
Args:
scene_name: 场景名称
scene_background: 场景背景
role_a_name: 角色A名称
role_b_name: 角色B名称
role_a_description: 角色A描述
role_b_description: 角色B描述
user_a_name: 用户A名称
user_b_name: 用户B名称
dialogue_history: 对话历史列表
duration_seconds: 对练时长(秒)
total_turns: 总对话轮次
db: 数据库会话
Returns:
DuoPracticeAnalysisResult: 分析结果
"""
try:
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
# 格式化对话历史
dialogue_text = self._format_dialogue_history(dialogue_history)
# 创建 AI 服务
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
# 构建用户提示词
user_prompt = USER_PROMPT.format(
scene_name=scene_name,
scene_background=scene_background or "未设置",
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description or f"扮演{role_a_name}角色",
role_b_description=role_b_description or f"扮演{role_b_name}角色",
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_text,
duration_seconds=duration_seconds,
total_turns=total_turns
)
# 调用 AI
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
ai_response = await ai_service.chat(
messages=messages,
model="gemini-3-flash-preview", # 使用快速模型
temperature=0.3,
prompt_name="duo_practice_analysis"
)
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
# 解析 AI 输出
result = self._parse_analysis_result(
ai_response.content,
user_a_name=user_a_name,
user_b_name=user_b_name,
role_a_name=role_a_name,
role_b_name=role_b_name
)
# 填充 AI 元数据
result.raw_response = ai_response.content
result.ai_provider = ai_response.provider
result.ai_model = ai_response.model
result.ai_latency_ms = ai_response.latency_ms
return result
except Exception as e:
logger.error(f"双人对练分析失败: {e}", exc_info=True)
# 返回空结果
return DuoPracticeAnalysisResult(
overall_comment=f"分析失败: {str(e)}"
)
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
"""格式化对话历史"""
lines = []
for d in dialogues:
speaker = d.get("role_name") or d.get("speaker", "未知")
content = d.get("content", "")
seq = d.get("sequence", 0)
lines.append(f"[{seq}] {speaker}{content}")
return "\n".join(lines)
def _parse_analysis_result(
self,
ai_output: str,
user_a_name: str,
user_b_name: str,
role_a_name: str,
role_b_name: str
) -> DuoPracticeAnalysisResult:
"""解析 AI 输出"""
result = DuoPracticeAnalysisResult()
try:
# 尝试提取 JSON
json_str = ai_output
# 如果输出包含 markdown 代码块,提取其中的 JSON
if "```json" in ai_output:
start = ai_output.find("```json") + 7
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
elif "```" in ai_output:
start = ai_output.find("```") + 3
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
data = json.loads(json_str)
# 解析整体评估
overall = data.get("overall_evaluation", {})
result.interaction_quality = overall.get("interaction_quality", 0)
result.scene_restoration = overall.get("scene_restoration", 0)
result.overall_comment = overall.get("overall_comment", "")
# 解析用户A评估
user_a_data = data.get("user_a_evaluation", {})
if user_a_data:
result.user_a_evaluation = UserEvaluation(
user_name=user_a_data.get("user_name", user_a_name),
role_name=user_a_data.get("role_name", role_a_name),
total_score=user_a_data.get("total_score", 0),
dimensions=user_a_data.get("dimensions", {}),
highlights=user_a_data.get("highlights", []),
improvements=user_a_data.get("improvements", [])
)
# 解析用户B评估
user_b_data = data.get("user_b_evaluation", {})
if user_b_data:
result.user_b_evaluation = UserEvaluation(
user_name=user_b_data.get("user_name", user_b_name),
role_name=user_b_data.get("role_name", role_b_name),
total_score=user_b_data.get("total_score", 0),
dimensions=user_b_data.get("dimensions", {}),
highlights=user_b_data.get("highlights", []),
improvements=user_b_data.get("improvements", [])
)
# 解析对话标注
result.dialogue_annotations = data.get("dialogue_annotations", [])
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e}")
result.overall_comment = "AI 输出格式异常,请重试"
except Exception as e:
logger.error(f"解析分析结果失败: {e}")
result.overall_comment = f"解析失败: {str(e)}"
return result
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
"""将结果转换为字典(用于 API 响应)"""
return {
"overall_evaluation": {
"interaction_quality": result.interaction_quality,
"scene_restoration": result.scene_restoration,
"overall_comment": result.overall_comment
},
"user_a_evaluation": {
"user_name": result.user_a_evaluation.user_name,
"role_name": result.user_a_evaluation.role_name,
"total_score": result.user_a_evaluation.total_score,
"dimensions": result.user_a_evaluation.dimensions,
"highlights": result.user_a_evaluation.highlights,
"improvements": result.user_a_evaluation.improvements
} if result.user_a_evaluation else None,
"user_b_evaluation": {
"user_name": result.user_b_evaluation.user_name,
"role_name": result.user_b_evaluation.role_name,
"total_score": result.user_b_evaluation.total_score,
"dimensions": result.user_b_evaluation.dimensions,
"highlights": result.user_b_evaluation.highlights,
"improvements": result.user_b_evaluation.improvements
} if result.user_b_evaluation else None,
"dialogue_annotations": result.dialogue_annotations,
"ai_metadata": {
"provider": result.ai_provider,
"model": result.ai_model,
"latency_ms": result.ai_latency_ms
}
}
# ==================== 全局实例 ====================
duo_practice_analysis_service = DuoPracticeAnalysisService()
# ==================== 便捷函数 ====================
async def analyze_duo_practice(
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""便捷函数:分析双人对练"""
return await duo_practice_analysis_service.analyze(
scene_name=scene_name,
scene_background=scene_background,
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description,
role_b_description=role_b_description,
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_history,
duration_seconds=duration_seconds,
total_turns=total_turns,
db=db
)

View File

@@ -1,207 +1,207 @@
"""
双人对练评估提示词模板
功能:评估双人角色扮演对练的表现
"""
# ==================== 元数据 ====================
PROMPT_META = {
"name": "duo_practice_analysis",
"display_name": "双人对练评估",
"description": "评估双人角色扮演对练中双方的表现",
"module": "kaopeilian",
"variables": [
"scene_name", "scene_background",
"role_a_name", "role_b_name",
"role_a_description", "role_b_description",
"user_a_name", "user_b_name",
"dialogue_history",
"duration_seconds", "total_turns"
],
"version": "1.0.0",
"author": "kaopeilian-team",
}
# ==================== 系统提示词 ====================
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
评估原则:
1. 客观公正,基于对话内容给出评价
2. 突出亮点,指出不足
3. 给出具体、可操作的改进建议
4. 考虑角色特点,评估角色代入度
输出格式要求:
- 必须返回有效的 JSON 格式
- 分数范围 0-100
- 建议具体可行"""
# ==================== 用户提示词模板 ====================
USER_PROMPT = """# 双人对练评估任务
## 场景信息
- **场景名称**{scene_name}
- **场景背景**{scene_background}
## 角色设置
### {role_a_name}
- **扮演者**{user_a_name}
- **角色描述**{role_a_description}
### {role_b_name}
- **扮演者**{user_b_name}
- **角色描述**{role_b_description}
## 对练数据
- **对练时长**{duration_seconds}
- **总对话轮次**{total_turns}
## 对话记录
{dialogue_history}
---
## 评估要求
请按以下 JSON 格式输出评估结果:
```json
{{
"overall_evaluation": {{
"interaction_quality": 85,
"scene_restoration": 80,
"overall_comment": "整体评价..."
}},
"user_a_evaluation": {{
"user_name": "{user_a_name}",
"role_name": "{role_a_name}",
"total_score": 85,
"dimensions": {{
"role_immersion": {{
"score": 85,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 80,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 75,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 82,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 78,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"user_b_evaluation": {{
"user_name": "{user_b_name}",
"role_name": "{role_b_name}",
"total_score": 82,
"dimensions": {{
"role_immersion": {{
"score": 80,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 85,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 78,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 80,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 75,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"dialogue_annotations": [
{{
"sequence": 1,
"speaker": "{role_a_name}",
"tags": ["good_opening"],
"comment": "开场白自然得体"
}},
{{
"sequence": 3,
"speaker": "{role_b_name}",
"tags": ["needs_improvement"],
"comment": "可以更主动表达需求"
}}
]
}}
```
请基于对话内容,给出客观、专业的评估。"""
# ==================== 维度说明 ====================
DIMENSION_DESCRIPTIONS = {
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
}
# ==================== 对话标签 ====================
DIALOGUE_TAGS = {
# 正面标签
"good_opening": "开场良好",
"active_listening": "积极倾听",
"empathy": "共情表达",
"professional": "专业表现",
"good_closing": "结束得体",
"creative_response": "创意回应",
"problem_solving": "问题解决",
# 需改进标签
"needs_improvement": "需要改进",
"off_topic": "偏离主题",
"too_passive": "过于被动",
"lack_detail": "缺乏细节",
"missed_opportunity": "错失机会",
"unclear_expression": "表达不清"
}
"""
双人对练评估提示词模板
功能:评估双人角色扮演对练的表现
"""
# ==================== 元数据 ====================
PROMPT_META = {
"name": "duo_practice_analysis",
"display_name": "双人对练评估",
"description": "评估双人角色扮演对练中双方的表现",
"module": "kaopeilian",
"variables": [
"scene_name", "scene_background",
"role_a_name", "role_b_name",
"role_a_description", "role_b_description",
"user_a_name", "user_b_name",
"dialogue_history",
"duration_seconds", "total_turns"
],
"version": "1.0.0",
"author": "kaopeilian-team",
}
# ==================== 系统提示词 ====================
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
评估原则:
1. 客观公正,基于对话内容给出评价
2. 突出亮点,指出不足
3. 给出具体、可操作的改进建议
4. 考虑角色特点,评估角色代入度
输出格式要求:
- 必须返回有效的 JSON 格式
- 分数范围 0-100
- 建议具体可行"""
# ==================== 用户提示词模板 ====================
USER_PROMPT = """# 双人对练评估任务
## 场景信息
- **场景名称**{scene_name}
- **场景背景**{scene_background}
## 角色设置
### {role_a_name}
- **扮演者**{user_a_name}
- **角色描述**{role_a_description}
### {role_b_name}
- **扮演者**{user_b_name}
- **角色描述**{role_b_description}
## 对练数据
- **对练时长**{duration_seconds}
- **总对话轮次**{total_turns}
## 对话记录
{dialogue_history}
---
## 评估要求
请按以下 JSON 格式输出评估结果:
```json
{{
"overall_evaluation": {{
"interaction_quality": 85,
"scene_restoration": 80,
"overall_comment": "整体评价..."
}},
"user_a_evaluation": {{
"user_name": "{user_a_name}",
"role_name": "{role_a_name}",
"total_score": 85,
"dimensions": {{
"role_immersion": {{
"score": 85,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 80,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 75,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 82,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 78,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"user_b_evaluation": {{
"user_name": "{user_b_name}",
"role_name": "{role_b_name}",
"total_score": 82,
"dimensions": {{
"role_immersion": {{
"score": 80,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 85,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 78,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 80,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 75,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"dialogue_annotations": [
{{
"sequence": 1,
"speaker": "{role_a_name}",
"tags": ["good_opening"],
"comment": "开场白自然得体"
}},
{{
"sequence": 3,
"speaker": "{role_b_name}",
"tags": ["needs_improvement"],
"comment": "可以更主动表达需求"
}}
]
}}
```
请基于对话内容,给出客观、专业的评估。"""
# ==================== 维度说明 ====================
DIMENSION_DESCRIPTIONS = {
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
}
# ==================== 对话标签 ====================
DIALOGUE_TAGS = {
# 正面标签
"good_opening": "开场良好",
"active_listening": "积极倾听",
"empathy": "共情表达",
"professional": "专业表现",
"good_closing": "结束得体",
"creative_response": "创意回应",
"problem_solving": "问题解决",
# 需改进标签
"needs_improvement": "需要改进",
"off_topic": "偏离主题",
"too_passive": "过于被动",
"lack_detail": "缺乏细节",
"missed_opportunity": "错失机会",
"unclear_expression": "表达不清"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,489 +1,489 @@
"""
数据大屏服务
提供企业级和团队级数据大屏功能:
- 学习数据概览
- 部门/团队对比
- 趋势分析
- 实时动态
"""
from datetime import datetime, timedelta, date
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_, or_, desc, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.exam import Exam
from app.models.practice import PracticeSession
from app.models.training import TrainingSession, TrainingReport
from app.models.level import UserLevel, ExpHistory, UserBadge
from app.models.position import Position
from app.models.position_member import PositionMember
logger = get_logger(__name__)
class DashboardService:
"""数据大屏服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
"""
获取企业级数据概览
Args:
enterprise_id: 企业ID可选用于多租户
Returns:
企业级数据概览
"""
today = date.today()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
# 基础统计
# 1. 总学员数
result = await self.db.execute(
select(func.count(User.id))
.where(User.is_deleted == False, User.role == 'trainee')
)
total_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录)
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == today)
)
today_active = result.scalar() or 0
# 3. 本周活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
)
week_active = result.scalar() or 0
# 4. 本月活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
)
month_active = result.scalar() or 0
# 5. 总学习时长(小时)
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed')
)
practice_hours = (result.scalar() or 0) / 3600
result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'COMPLETED')
)
training_hours = (result.scalar() or 0) / 3600
total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score)
)
.where(Exam.status == 'submitted')
)
exam_row = result.first()
exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0
exam_avg_score = round(exam_row[2] or 0, 1)
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
# 7. 满分人数
result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id)))
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
)
perfect_users = result.scalar() or 0
# 8. 签到率(今日签到人数/总用户数)
result = await self.db.execute(
select(func.count(UserLevel.id))
.where(func.date(UserLevel.last_login_date) == today)
)
today_checkin = result.scalar() or 0
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
return {
"overview": {
"total_users": total_users,
"today_active": today_active,
"week_active": week_active,
"month_active": month_active,
"total_hours": total_hours,
"checkin_rate": checkin_rate,
},
"exam": {
"total_count": exam_count,
"pass_rate": exam_pass_rate,
"avg_score": exam_avg_score,
"perfect_users": perfect_users,
},
"updated_at": datetime.now().isoformat()
}
async def get_department_comparison(self) -> List[Dict[str, Any]]:
"""
获取部门/团队学习对比数据
Returns:
部门对比列表
"""
# 获取所有岗位及其成员的学习数据
result = await self.db.execute(
select(Position)
.where(Position.is_deleted == False)
.order_by(Position.name)
)
positions = result.scalars().all()
departments = []
for pos in positions:
# 获取该岗位的成员数
result = await self.db.execute(
select(func.count(PositionMember.id))
.where(PositionMember.position_id == pos.id)
)
member_count = result.scalar() or 0
if member_count == 0:
continue
# 获取成员ID列表
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id == pos.id)
)
member_ids = [row[0] for row in result.all()]
# 统计该岗位成员的学习数据
# 考试通过率
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1)))
)
.where(
Exam.user_id.in_(member_ids),
Exam.status == 'submitted'
)
)
exam_row = result.first()
exam_total = exam_row[0] or 0
exam_passed = exam_row[1] or 0
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
# 平均学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
PracticeSession.user_id.in_(member_ids),
PracticeSession.status == 'completed'
)
)
total_seconds = result.scalar() or 0
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
# 平均等级
result = await self.db.execute(
select(func.avg(UserLevel.level))
.where(UserLevel.user_id.in_(member_ids))
)
avg_level = round(result.scalar() or 1, 1)
departments.append({
"id": pos.id,
"name": pos.name,
"member_count": member_count,
"pass_rate": pass_rate,
"avg_hours": avg_hours,
"avg_level": avg_level,
})
# 按通过率排序
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
return departments
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
"""
获取学习趋势数据
Args:
days: 统计天数
Returns:
趋势数据
"""
today = date.today()
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
trend_data = []
for d in dates:
# 当日活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == d)
)
active_users = result.scalar() or 0
# 当日新增学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
func.date(PracticeSession.created_at) == d,
PracticeSession.status == 'completed'
)
)
hours = round((result.scalar() or 0) / 3600, 1)
# 当日考试次数
result = await self.db.execute(
select(func.count(Exam.id))
.where(
func.date(Exam.created_at) == d,
Exam.status == 'submitted'
)
)
exams = result.scalar() or 0
trend_data.append({
"date": d.isoformat(),
"active_users": active_users,
"learning_hours": hours,
"exam_count": exams,
})
return {
"dates": [d.isoformat() for d in dates],
"trend": trend_data
}
async def get_level_distribution(self) -> Dict[str, Any]:
"""
获取等级分布数据
Returns:
等级分布
"""
result = await self.db.execute(
select(UserLevel.level, func.count(UserLevel.id))
.group_by(UserLevel.level)
.order_by(UserLevel.level)
)
rows = result.all()
distribution = {row[0]: row[1] for row in rows}
# 补全1-10级
for i in range(1, 11):
if i not in distribution:
distribution[i] = 0
return {
"levels": list(range(1, 11)),
"counts": [distribution.get(i, 0) for i in range(1, 11)]
}
async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
获取实时动态
Args:
limit: 数量限制
Returns:
实时动态列表
"""
activities = []
# 获取最近的经验值记录
result = await self.db.execute(
select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc())
.limit(limit)
)
rows = result.all()
for exp, user in rows:
activity_type = "学习"
if "考试" in (exp.description or ""):
activity_type = "考试"
elif "签到" in (exp.description or ""):
activity_type = "签到"
elif "陪练" in (exp.description or ""):
activity_type = "陪练"
elif "奖章" in (exp.description or ""):
activity_type = "奖章"
activities.append({
"id": exp.id,
"user_id": user.id,
"user_name": user.full_name or user.username,
"type": activity_type,
"description": exp.description,
"exp_amount": exp.exp_amount,
"created_at": exp.created_at.isoformat() if exp.created_at else None,
})
return activities
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
"""
获取团队级数据大屏
Args:
team_leader_id: 团队负责人ID
Returns:
团队数据
"""
# 获取团队负责人管理的岗位
result = await self.db.execute(
select(Position)
.where(
Position.is_deleted == False,
or_(
Position.manager_id == team_leader_id,
Position.created_by == team_leader_id
)
)
)
positions = result.scalars().all()
position_ids = [p.id for p in positions]
if not position_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取团队成员
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id.in_(position_ids))
)
member_ids = [row[0] for row in result.all()]
if not member_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取成员详细信息
result = await self.db.execute(
select(User, UserLevel)
.outerjoin(UserLevel, User.id == UserLevel.user_id)
.where(User.id.in_(member_ids))
.order_by(UserLevel.total_exp.desc().nullslast())
)
rows = result.all()
members = []
total_exp = 0
total_level = 0
for user, level in rows:
user_level = level.level if level else 1
user_exp = level.total_exp if level else 0
total_level += user_level
total_exp += user_exp
# 获取用户奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id == user.id)
)
badge_count = result.scalar() or 0
members.append({
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"level": user_level,
"total_exp": user_exp,
"badge_count": badge_count,
})
total_members = len(members)
# 获取团队总奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id.in_(member_ids))
)
total_badges = result.scalar() or 0
return {
"members": members,
"overview": {
"total_members": total_members,
"avg_level": round(total_level / total_members, 1) if total_members > 0 else 0,
"avg_exp": round(total_exp / total_members) if total_members > 0 else 0,
"total_badges": total_badges,
},
"positions": [{"id": p.id, "name": p.name} for p in positions]
}
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
"""
获取课程热度排行
Args:
limit: 数量限制
Returns:
课程排行列表
"""
# 这里简化实现,实际应该统计课程学习次数
result = await self.db.execute(
select(Course)
.where(Course.is_deleted == False, Course.is_published == True)
.order_by(Course.created_at.desc())
.limit(limit)
)
courses = result.scalars().all()
ranking = []
for i, course in enumerate(courses, 1):
ranking.append({
"rank": i,
"id": course.id,
"name": course.name,
"description": course.description,
# 这里可以添加实际的学习人数统计
"learners": 0,
})
return ranking
"""
数据大屏服务
提供企业级和团队级数据大屏功能:
- 学习数据概览
- 部门/团队对比
- 趋势分析
- 实时动态
"""
from datetime import datetime, timedelta, date
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func, and_, or_, desc, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.exam import Exam
from app.models.practice import PracticeSession
from app.models.training import TrainingSession, TrainingReport
from app.models.level import UserLevel, ExpHistory, UserBadge
from app.models.position import Position
from app.models.position_member import PositionMember
logger = get_logger(__name__)
class DashboardService:
"""数据大屏服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
"""
获取企业级数据概览
Args:
enterprise_id: 企业ID可选用于多租户
Returns:
企业级数据概览
"""
today = date.today()
week_ago = today - timedelta(days=7)
month_ago = today - timedelta(days=30)
# 基础统计
# 1. 总学员数
result = await self.db.execute(
select(func.count(User.id))
.where(User.is_deleted == False, User.role == 'trainee')
)
total_users = result.scalar() or 0
# 2. 今日活跃用户(有经验值记录)
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == today)
)
today_active = result.scalar() or 0
# 3. 本周活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
)
week_active = result.scalar() or 0
# 4. 本月活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
)
month_active = result.scalar() or 0
# 5. 总学习时长(小时)
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(PracticeSession.status == 'completed')
)
practice_hours = (result.scalar() or 0) / 3600
result = await self.db.execute(
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
.where(TrainingSession.status == 'COMPLETED')
)
training_hours = (result.scalar() or 0) / 3600
total_hours = round(practice_hours + training_hours, 1)
# 6. 考试统计
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1))),
func.avg(Exam.score)
)
.where(Exam.status == 'submitted')
)
exam_row = result.first()
exam_count = exam_row[0] or 0
exam_passed = exam_row[1] or 0
exam_avg_score = round(exam_row[2] or 0, 1)
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
# 7. 满分人数
result = await self.db.execute(
select(func.count(func.distinct(Exam.user_id)))
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
)
perfect_users = result.scalar() or 0
# 8. 签到率(今日签到人数/总用户数)
result = await self.db.execute(
select(func.count(UserLevel.id))
.where(func.date(UserLevel.last_login_date) == today)
)
today_checkin = result.scalar() or 0
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
return {
"overview": {
"total_users": total_users,
"today_active": today_active,
"week_active": week_active,
"month_active": month_active,
"total_hours": total_hours,
"checkin_rate": checkin_rate,
},
"exam": {
"total_count": exam_count,
"pass_rate": exam_pass_rate,
"avg_score": exam_avg_score,
"perfect_users": perfect_users,
},
"updated_at": datetime.now().isoformat()
}
async def get_department_comparison(self) -> List[Dict[str, Any]]:
"""
获取部门/团队学习对比数据
Returns:
部门对比列表
"""
# 获取所有岗位及其成员的学习数据
result = await self.db.execute(
select(Position)
.where(Position.is_deleted == False)
.order_by(Position.name)
)
positions = result.scalars().all()
departments = []
for pos in positions:
# 获取该岗位的成员数
result = await self.db.execute(
select(func.count(PositionMember.id))
.where(PositionMember.position_id == pos.id)
)
member_count = result.scalar() or 0
if member_count == 0:
continue
# 获取成员ID列表
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id == pos.id)
)
member_ids = [row[0] for row in result.all()]
# 统计该岗位成员的学习数据
# 考试通过率
result = await self.db.execute(
select(
func.count(Exam.id),
func.count(case((Exam.is_passed == True, 1)))
)
.where(
Exam.user_id.in_(member_ids),
Exam.status == 'submitted'
)
)
exam_row = result.first()
exam_total = exam_row[0] or 0
exam_passed = exam_row[1] or 0
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
# 平均学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
PracticeSession.user_id.in_(member_ids),
PracticeSession.status == 'completed'
)
)
total_seconds = result.scalar() or 0
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
# 平均等级
result = await self.db.execute(
select(func.avg(UserLevel.level))
.where(UserLevel.user_id.in_(member_ids))
)
avg_level = round(result.scalar() or 1, 1)
departments.append({
"id": pos.id,
"name": pos.name,
"member_count": member_count,
"pass_rate": pass_rate,
"avg_hours": avg_hours,
"avg_level": avg_level,
})
# 按通过率排序
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
return departments
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
"""
获取学习趋势数据
Args:
days: 统计天数
Returns:
趋势数据
"""
today = date.today()
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
trend_data = []
for d in dates:
# 当日活跃用户
result = await self.db.execute(
select(func.count(func.distinct(ExpHistory.user_id)))
.where(func.date(ExpHistory.created_at) == d)
)
active_users = result.scalar() or 0
# 当日新增学习时长
result = await self.db.execute(
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
.where(
func.date(PracticeSession.created_at) == d,
PracticeSession.status == 'completed'
)
)
hours = round((result.scalar() or 0) / 3600, 1)
# 当日考试次数
result = await self.db.execute(
select(func.count(Exam.id))
.where(
func.date(Exam.created_at) == d,
Exam.status == 'submitted'
)
)
exams = result.scalar() or 0
trend_data.append({
"date": d.isoformat(),
"active_users": active_users,
"learning_hours": hours,
"exam_count": exams,
})
return {
"dates": [d.isoformat() for d in dates],
"trend": trend_data
}
async def get_level_distribution(self) -> Dict[str, Any]:
"""
获取等级分布数据
Returns:
等级分布
"""
result = await self.db.execute(
select(UserLevel.level, func.count(UserLevel.id))
.group_by(UserLevel.level)
.order_by(UserLevel.level)
)
rows = result.all()
distribution = {row[0]: row[1] for row in rows}
# 补全1-10级
for i in range(1, 11):
if i not in distribution:
distribution[i] = 0
return {
"levels": list(range(1, 11)),
"counts": [distribution.get(i, 0) for i in range(1, 11)]
}
async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
获取实时动态
Args:
limit: 数量限制
Returns:
实时动态列表
"""
activities = []
# 获取最近的经验值记录
result = await self.db.execute(
select(ExpHistory, User)
.join(User, ExpHistory.user_id == User.id)
.order_by(ExpHistory.created_at.desc())
.limit(limit)
)
rows = result.all()
for exp, user in rows:
activity_type = "学习"
if "考试" in (exp.description or ""):
activity_type = "考试"
elif "签到" in (exp.description or ""):
activity_type = "签到"
elif "陪练" in (exp.description or ""):
activity_type = "陪练"
elif "奖章" in (exp.description or ""):
activity_type = "奖章"
activities.append({
"id": exp.id,
"user_id": user.id,
"user_name": user.full_name or user.username,
"type": activity_type,
"description": exp.description,
"exp_amount": exp.exp_amount,
"created_at": exp.created_at.isoformat() if exp.created_at else None,
})
return activities
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
"""
获取团队级数据大屏
Args:
team_leader_id: 团队负责人ID
Returns:
团队数据
"""
# 获取团队负责人管理的岗位
result = await self.db.execute(
select(Position)
.where(
Position.is_deleted == False,
or_(
Position.manager_id == team_leader_id,
Position.created_by == team_leader_id
)
)
)
positions = result.scalars().all()
position_ids = [p.id for p in positions]
if not position_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取团队成员
result = await self.db.execute(
select(PositionMember.user_id)
.where(PositionMember.position_id.in_(position_ids))
)
member_ids = [row[0] for row in result.all()]
if not member_ids:
return {
"members": [],
"overview": {
"total_members": 0,
"avg_level": 0,
"avg_exp": 0,
"total_badges": 0,
},
"pending_tasks": []
}
# 获取成员详细信息
result = await self.db.execute(
select(User, UserLevel)
.outerjoin(UserLevel, User.id == UserLevel.user_id)
.where(User.id.in_(member_ids))
.order_by(UserLevel.total_exp.desc().nullslast())
)
rows = result.all()
members = []
total_exp = 0
total_level = 0
for user, level in rows:
user_level = level.level if level else 1
user_exp = level.total_exp if level else 0
total_level += user_level
total_exp += user_exp
# 获取用户奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id == user.id)
)
badge_count = result.scalar() or 0
members.append({
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"level": user_level,
"total_exp": user_exp,
"badge_count": badge_count,
})
total_members = len(members)
# 获取团队总奖章数
result = await self.db.execute(
select(func.count(UserBadge.id))
.where(UserBadge.user_id.in_(member_ids))
)
total_badges = result.scalar() or 0
return {
"members": members,
"overview": {
"total_members": total_members,
"avg_level": round(total_level / total_members, 1) if total_members > 0 else 0,
"avg_exp": round(total_exp / total_members) if total_members > 0 else 0,
"total_badges": total_badges,
},
"positions": [{"id": p.id, "name": p.name} for p in positions]
}
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
"""
获取课程热度排行
Args:
limit: 数量限制
Returns:
课程排行列表
"""
# 这里简化实现,实际应该统计课程学习次数
result = await self.db.execute(
select(Course)
.where(Course.is_deleted == False, Course.is_published == True)
.order_by(Course.created_at.desc())
.limit(limit)
)
courses = result.scalars().all()
ranking = []
for i, course in enumerate(courses, 1):
ranking.append({
"rank": i,
"id": course.id,
"name": course.name,
"description": course.description,
# 这里可以添加实际的学习人数统计
"learners": 0,
})
return ranking

View File

@@ -1,302 +1,302 @@
"""
钉钉认证服务
提供钉钉免密登录功能,从数据库读取配置
"""
import json
import time
from typing import Optional, Dict, Any, Tuple
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.core.security import create_access_token, create_refresh_token
from app.models.user import User
from app.schemas.auth import Token
from app.services.user_service import UserService
logger = get_logger(__name__)
# 钉钉API地址
DINGTALK_API_BASE = "https://oapi.dingtalk.com"
class DingtalkAuthService:
"""钉钉认证服务"""
def __init__(self, db: AsyncSession):
self.db = db
self.user_service = UserService(db)
self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time)
async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]:
"""
从数据库获取钉钉配置
Args:
tenant_id: 租户ID
Returns:
配置字典 {app_key, app_secret, agent_id, corp_id}
"""
result = await self.db.execute(
text("""
SELECT config_key, config_value
FROM tenant_configs
WHERE tenant_id = :tenant_id AND config_group = 'dingtalk'
"""),
{"tenant_id": tenant_id}
)
rows = result.fetchall()
config = {}
key_mapping = {
"DINGTALK_APP_KEY": "app_key",
"DINGTALK_APP_SECRET": "app_secret",
"DINGTALK_AGENT_ID": "agent_id",
"DINGTALK_CORP_ID": "corp_id",
}
for row in rows:
if row[0] in key_mapping:
config[key_mapping[row[0]]] = row[1]
return config
async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool:
"""
检查钉钉免密登录功能是否启用
Args:
tenant_id: 租户ID
Returns:
是否启用
"""
# 先查租户级别的配置
result = await self.db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = 'dingtalk_login'
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
ORDER BY tenant_id DESC
LIMIT 1
"""),
{"tenant_id": tenant_id}
)
row = result.fetchone()
if row:
return bool(row[0])
return False
async def get_access_token(self, tenant_id: int) -> str:
"""
获取钉钉访问令牌(带内存缓存)
Args:
tenant_id: 租户ID
Returns:
access_token
Raises:
Exception: 获取失败时抛出异常
"""
# 检查缓存
if tenant_id in self._access_token_cache:
token, expire_time = self._access_token_cache[tenant_id]
if time.time() < expire_time - 300: # 提前5分钟刷新
return token
# 获取配置
config = await self.get_dingtalk_config(tenant_id)
if not config.get("app_key") or not config.get("app_secret"):
raise ValueError("钉钉配置不完整请在管理后台配置AppKey和AppSecret")
# 调用钉钉API获取token
url = f"{DINGTALK_API_BASE}/gettoken"
params = {
"appkey": config["app_key"],
"appsecret": config["app_secret"],
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"获取钉钉access_token失败: {error_msg}")
raise Exception(f"获取钉钉access_token失败: {error_msg}")
access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
# 缓存token
self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in)
logger.info(f"获取钉钉access_token成功有效期: {expires_in}")
return access_token
async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]:
"""
通过免登码获取钉钉用户信息
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
用户信息 {userid, name, ...}
Raises:
Exception: 获取失败时抛出异常
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo"
params = {"access_token": access_token}
payload = {"code": code}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"通过code获取钉钉用户信息失败: {error_msg}")
raise Exception(f"获取钉钉用户信息失败: {error_msg}")
result = data.get("result", {})
logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}")
return result
async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]:
"""
获取钉钉用户详细信息
Args:
tenant_id: 租户ID
userid: 钉钉用户ID
Returns:
用户详细信息
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/get"
params = {"access_token": access_token}
payload = {"userid": userid}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.warning(f"获取钉钉用户详情失败: {error_msg}")
return {}
return data.get("result", {})
async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]:
"""
钉钉免密登录主流程
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
(用户对象, Token对象)
Raises:
Exception: 登录失败时抛出异常
"""
# 1. 检查功能是否启用
if not await self.is_dingtalk_login_enabled(tenant_id):
raise Exception("钉钉免密登录功能未启用")
# 2. 通过code获取钉钉用户信息
dingtalk_user = await self.get_user_info_by_code(tenant_id, code)
dingtalk_userid = dingtalk_user.get("userid")
if not dingtalk_userid:
raise Exception("无法获取钉钉用户ID")
# 3. 根据dingtalk_id查找系统用户
logger.info(f"开始查找用户钉钉userid: {dingtalk_userid}")
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
if not user:
logger.info(f"通过dingtalk_id未找到用户尝试手机号匹配")
# 尝试通过手机号匹配
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
mobile = user_detail.get("mobile")
logger.info(f"获取到钉钉用户手机号: {mobile}")
if mobile:
user = await self.user_service.get_by_phone(mobile)
if user:
# 绑定dingtalk_id
user.dingtalk_id = dingtalk_userid
await self.db.commit()
logger.info(f"通过手机号匹配成功已绑定dingtalk_id: {dingtalk_userid}")
else:
logger.warning(f"通过手机号 {mobile} 也未找到用户")
else:
logger.warning("无法获取钉钉用户手机号")
if not user:
logger.error(f"钉钉登录失败dingtalk_userid={dingtalk_userid}, 未找到对应用户")
raise Exception("未找到对应的系统用户,请联系管理员")
if not user.is_active:
raise Exception("用户已被禁用")
# 4. 生成JWT Token
access_token = create_access_token(subject=user.id)
refresh_token = create_refresh_token(subject=user.id)
# 5. 更新最后登录时间
await self.user_service.update_last_login(user.id)
logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}")
return user, Token(
access_token=access_token,
refresh_token=refresh_token,
)
async def get_public_config(self, tenant_id: int) -> Dict[str, Any]:
"""
获取钉钉公开配置前端需要用于初始化JSDK
Args:
tenant_id: 租户ID
Returns:
{corp_id, agent_id, enabled}
"""
enabled = await self.is_dingtalk_login_enabled(tenant_id)
if not enabled:
return {
"enabled": False,
"corp_id": None,
"agent_id": None,
}
config = await self.get_dingtalk_config(tenant_id)
return {
"enabled": True,
"corp_id": config.get("corp_id"),
"agent_id": config.get("agent_id"),
}
"""
钉钉认证服务
提供钉钉免密登录功能,从数据库读取配置
"""
import json
import time
from typing import Optional, Dict, Any, Tuple
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logger import get_logger
from app.core.security import create_access_token, create_refresh_token
from app.models.user import User
from app.schemas.auth import Token
from app.services.user_service import UserService
logger = get_logger(__name__)
# 钉钉API地址
DINGTALK_API_BASE = "https://oapi.dingtalk.com"
class DingtalkAuthService:
"""钉钉认证服务"""
def __init__(self, db: AsyncSession):
self.db = db
self.user_service = UserService(db)
self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time)
async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]:
"""
从数据库获取钉钉配置
Args:
tenant_id: 租户ID
Returns:
配置字典 {app_key, app_secret, agent_id, corp_id}
"""
result = await self.db.execute(
text("""
SELECT config_key, config_value
FROM tenant_configs
WHERE tenant_id = :tenant_id AND config_group = 'dingtalk'
"""),
{"tenant_id": tenant_id}
)
rows = result.fetchall()
config = {}
key_mapping = {
"DINGTALK_APP_KEY": "app_key",
"DINGTALK_APP_SECRET": "app_secret",
"DINGTALK_AGENT_ID": "agent_id",
"DINGTALK_CORP_ID": "corp_id",
}
for row in rows:
if row[0] in key_mapping:
config[key_mapping[row[0]]] = row[1]
return config
async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool:
"""
检查钉钉免密登录功能是否启用
Args:
tenant_id: 租户ID
Returns:
是否启用
"""
# 先查租户级别的配置
result = await self.db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = 'dingtalk_login'
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
ORDER BY tenant_id DESC
LIMIT 1
"""),
{"tenant_id": tenant_id}
)
row = result.fetchone()
if row:
return bool(row[0])
return False
async def get_access_token(self, tenant_id: int) -> str:
"""
获取钉钉访问令牌(带内存缓存)
Args:
tenant_id: 租户ID
Returns:
access_token
Raises:
Exception: 获取失败时抛出异常
"""
# 检查缓存
if tenant_id in self._access_token_cache:
token, expire_time = self._access_token_cache[tenant_id]
if time.time() < expire_time - 300: # 提前5分钟刷新
return token
# 获取配置
config = await self.get_dingtalk_config(tenant_id)
if not config.get("app_key") or not config.get("app_secret"):
raise ValueError("钉钉配置不完整请在管理后台配置AppKey和AppSecret")
# 调用钉钉API获取token
url = f"{DINGTALK_API_BASE}/gettoken"
params = {
"appkey": config["app_key"],
"appsecret": config["app_secret"],
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"获取钉钉access_token失败: {error_msg}")
raise Exception(f"获取钉钉access_token失败: {error_msg}")
access_token = data["access_token"]
expires_in = data.get("expires_in", 7200)
# 缓存token
self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in)
logger.info(f"获取钉钉access_token成功有效期: {expires_in}")
return access_token
async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]:
"""
通过免登码获取钉钉用户信息
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
用户信息 {userid, name, ...}
Raises:
Exception: 获取失败时抛出异常
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo"
params = {"access_token": access_token}
payload = {"code": code}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.error(f"通过code获取钉钉用户信息失败: {error_msg}")
raise Exception(f"获取钉钉用户信息失败: {error_msg}")
result = data.get("result", {})
logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}")
return result
async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]:
"""
获取钉钉用户详细信息
Args:
tenant_id: 租户ID
userid: 钉钉用户ID
Returns:
用户详细信息
"""
access_token = await self.get_access_token(tenant_id)
url = f"{DINGTALK_API_BASE}/topapi/v2/user/get"
params = {"access_token": access_token}
payload = {"userid": userid}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, params=params, json=payload)
data = response.json()
if data.get("errcode") != 0:
error_msg = data.get("errmsg", "未知错误")
logger.warning(f"获取钉钉用户详情失败: {error_msg}")
return {}
return data.get("result", {})
async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]:
"""
钉钉免密登录主流程
Args:
tenant_id: 租户ID
code: 免登授权码
Returns:
(用户对象, Token对象)
Raises:
Exception: 登录失败时抛出异常
"""
# 1. 检查功能是否启用
if not await self.is_dingtalk_login_enabled(tenant_id):
raise Exception("钉钉免密登录功能未启用")
# 2. 通过code获取钉钉用户信息
dingtalk_user = await self.get_user_info_by_code(tenant_id, code)
dingtalk_userid = dingtalk_user.get("userid")
if not dingtalk_userid:
raise Exception("无法获取钉钉用户ID")
# 3. 根据dingtalk_id查找系统用户
logger.info(f"开始查找用户钉钉userid: {dingtalk_userid}")
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
if not user:
logger.info(f"通过dingtalk_id未找到用户尝试手机号匹配")
# 尝试通过手机号匹配
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
mobile = user_detail.get("mobile")
logger.info(f"获取到钉钉用户手机号: {mobile}")
if mobile:
user = await self.user_service.get_by_phone(mobile)
if user:
# 绑定dingtalk_id
user.dingtalk_id = dingtalk_userid
await self.db.commit()
logger.info(f"通过手机号匹配成功已绑定dingtalk_id: {dingtalk_userid}")
else:
logger.warning(f"通过手机号 {mobile} 也未找到用户")
else:
logger.warning("无法获取钉钉用户手机号")
if not user:
logger.error(f"钉钉登录失败dingtalk_userid={dingtalk_userid}, 未找到对应用户")
raise Exception("未找到对应的系统用户,请联系管理员")
if not user.is_active:
raise Exception("用户已被禁用")
# 4. 生成JWT Token
access_token = create_access_token(subject=user.id)
refresh_token = create_refresh_token(subject=user.id)
# 5. 更新最后登录时间
await self.user_service.update_last_login(user.id)
logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}")
return user, Token(
access_token=access_token,
refresh_token=refresh_token,
)
async def get_public_config(self, tenant_id: int) -> Dict[str, Any]:
"""
获取钉钉公开配置前端需要用于初始化JSDK
Args:
tenant_id: 租户ID
Returns:
{corp_id, agent_id, enabled}
"""
enabled = await self.is_dingtalk_login_enabled(tenant_id)
if not enabled:
return {
"enabled": False,
"corp_id": None,
"agent_id": None,
}
config = await self.get_dingtalk_config(tenant_id)
return {
"enabled": True,
"corp_id": config.get("corp_id"),
"agent_id": config.get("agent_id"),
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,330 +1,419 @@
"""
站内消息通知服务
提供通知的CRUD操作和业务逻辑
通知推送服务
支持钉钉、企业微信、站内消息等多种渠道
"""
from typing import List, Optional, Tuple
from sqlalchemy import select, and_, desc, func, update
from sqlalchemy.orm import selectinload
import os
import json
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.core.logger import get_logger
from app.models.notification import Notification
from app.models.user import User
from app.schemas.notification import (
NotificationCreate,
NotificationBatchCreate,
NotificationResponse,
NotificationType,
)
from app.services.base_service import BaseService
from app.models.notification import Notification
logger = get_logger(__name__)
logger = logging.getLogger(__name__)
class NotificationService(BaseService[Notification]):
"""
站内消息通知服务
class NotificationChannel:
"""通知渠道基类"""
提供通知的创建、查询、标记已读等功能
"""
def __init__(self):
super().__init__(Notification)
async def create_notification(
async def send(
self,
db: AsyncSession,
notification_in: NotificationCreate
) -> Notification:
"""
创建单个通知
Args:
db: 数据库会话
notification_in: 通知创建数据
Returns:
创建的通知对象
"""
notification = Notification(
user_id=notification_in.user_id,
title=notification_in.title,
content=notification_in.content,
type=notification_in.type.value if isinstance(notification_in.type, NotificationType) else notification_in.type,
related_id=notification_in.related_id,
related_type=notification_in.related_type,
sender_id=notification_in.sender_id,
is_read=False
)
db.add(notification)
await db.commit()
await db.refresh(notification)
logger.info(
"创建通知成功",
notification_id=notification.id,
user_id=notification_in.user_id,
type=notification_in.type
)
return notification
async def batch_create_notifications(
self,
db: AsyncSession,
batch_in: NotificationBatchCreate
) -> List[Notification]:
"""
批量创建通知(发送给多个用户)
Args:
db: 数据库会话
batch_in: 批量通知创建数据
Returns:
创建的通知列表
"""
notifications = []
notification_type = batch_in.type.value if isinstance(batch_in.type, NotificationType) else batch_in.type
for user_id in batch_in.user_ids:
notification = Notification(
user_id=user_id,
title=batch_in.title,
content=batch_in.content,
type=notification_type,
related_id=batch_in.related_id,
related_type=batch_in.related_type,
sender_id=batch_in.sender_id,
is_read=False
)
notifications.append(notification)
db.add(notification)
await db.commit()
# 刷新所有对象
for notification in notifications:
await db.refresh(notification)
logger.info(
"批量创建通知成功",
count=len(notifications),
user_ids=batch_in.user_ids,
type=batch_in.type
)
return notifications
async def get_user_notifications(
self,
db: AsyncSession,
user_id: int,
skip: int = 0,
limit: int = 20,
is_read: Optional[bool] = None,
notification_type: Optional[str] = None
) -> Tuple[List[NotificationResponse], int, int]:
"""
获取用户的通知列表
Args:
db: 数据库会话
user_id: 用户ID
skip: 跳过数量
limit: 返回数量
is_read: 是否已读筛选
notification_type: 通知类型筛选
Returns:
(通知列表, 总数, 未读数)
"""
# 构建基础查询条件
conditions = [Notification.user_id == user_id]
if is_read is not None:
conditions.append(Notification.is_read == is_read)
if notification_type:
conditions.append(Notification.type == notification_type)
# 查询通知列表(带发送者信息)
stmt = (
select(Notification)
.where(and_(*conditions))
.order_by(desc(Notification.created_at))
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
notifications = result.scalars().all()
# 统计总数
count_stmt = select(func.count()).select_from(Notification).where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# 统计未读数
unread_stmt = (
select(func.count())
.select_from(Notification)
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
)
unread_result = await db.execute(unread_stmt)
unread_count = unread_result.scalar_one()
# 获取发送者信息
sender_ids = [n.sender_id for n in notifications if n.sender_id]
sender_names = {}
if sender_ids:
sender_stmt = select(User.id, User.full_name).where(User.id.in_(sender_ids))
sender_result = await db.execute(sender_stmt)
sender_names = {row[0]: row[1] for row in sender_result.fetchall()}
# 构建响应
responses = []
for notification in notifications:
response = NotificationResponse(
id=notification.id,
user_id=notification.user_id,
title=notification.title,
content=notification.content,
type=notification.type,
is_read=notification.is_read,
related_id=notification.related_id,
related_type=notification.related_type,
sender_id=notification.sender_id,
sender_name=sender_names.get(notification.sender_id) if notification.sender_id else None,
created_at=notification.created_at,
updated_at=notification.updated_at
)
responses.append(response)
return responses, total, unread_count
async def get_unread_count(
self,
db: AsyncSession,
user_id: int
) -> Tuple[int, int]:
"""
获取用户未读通知数量
Args:
db: 数据库会话
user_id: 用户ID
Returns:
(未读数, 总数)
"""
# 统计未读数
unread_stmt = (
select(func.count())
.select_from(Notification)
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
)
unread_result = await db.execute(unread_stmt)
unread_count = unread_result.scalar_one()
# 统计总数
total_stmt = (
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user_id)
)
total_result = await db.execute(total_stmt)
total = total_result.scalar_one()
return unread_count, total
async def mark_as_read(
self,
db: AsyncSession,
user_id: int,
notification_ids: Optional[List[int]] = None
) -> int:
"""
标记通知为已读
Args:
db: 数据库会话
user_id: 用户ID
notification_ids: 通知ID列表为空则标记全部
Returns:
更新的数量
"""
conditions = [
Notification.user_id == user_id,
Notification.is_read == False
]
if notification_ids:
conditions.append(Notification.id.in_(notification_ids))
stmt = (
update(Notification)
.where(and_(*conditions))
.values(is_read=True)
)
result = await db.execute(stmt)
await db.commit()
updated_count = result.rowcount
logger.info(
"标记通知已读",
user_id=user_id,
notification_ids=notification_ids,
updated_count=updated_count
)
return updated_count
async def delete_notification(
self,
db: AsyncSession,
user_id: int,
notification_id: int
title: str,
content: str,
**kwargs
) -> bool:
"""
删除通知
发送通知
Args:
db: 数据库会话
user_id: 用户ID
notification_id: 通知ID
title: 通知标题
content: 通知内容
Returns:
是否删除成功
是否发送成功
"""
stmt = select(Notification).where(
and_(
Notification.id == notification_id,
Notification.user_id == user_id
raise NotImplementedError
class DingtalkChannel(NotificationChannel):
"""
钉钉通知渠道
使用钉钉工作通知 API 发送消息
文档: https://open.dingtalk.com/document/orgapp/asynchronous-sending-of-enterprise-session-messages
"""
def __init__(
self,
app_key: Optional[str] = None,
app_secret: Optional[str] = None,
agent_id: Optional[str] = None,
):
self.app_key = app_key or os.getenv("DINGTALK_APP_KEY")
self.app_secret = app_secret or os.getenv("DINGTALK_APP_SECRET")
self.agent_id = agent_id or os.getenv("DINGTALK_AGENT_ID")
self._access_token = None
self._token_expires_at = None
async def _get_access_token(self) -> str:
"""获取钉钉访问令牌"""
if (
self._access_token
and self._token_expires_at
and datetime.now() < self._token_expires_at
):
return self._access_token
url = "https://oapi.dingtalk.com/gettoken"
params = {
"appkey": self.app_key,
"appsecret": self.app_secret,
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
self._access_token = result["access_token"]
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
return self._access_token
else:
raise Exception(f"获取钉钉Token失败: {result.get('errmsg')}")
async def send(
self,
user_id: int,
title: str,
content: str,
dingtalk_user_id: Optional[str] = None,
**kwargs
) -> bool:
"""发送钉钉工作通知"""
if not all([self.app_key, self.app_secret, self.agent_id]):
logger.warning("钉钉配置不完整,跳过发送")
return False
if not dingtalk_user_id:
logger.warning(f"用户 {user_id} 没有绑定钉钉ID")
return False
try:
access_token = await self._get_access_token()
url = f"https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={access_token}"
# 构建消息体
msg = {
"agent_id": self.agent_id,
"userid_list": dingtalk_user_id,
"msg": {
"msgtype": "text",
"text": {
"content": f"{title}\n\n{content}"
}
}
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=msg, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
logger.info(f"钉钉消息发送成功: user_id={user_id}")
return True
else:
logger.error(f"钉钉消息发送失败: {result.get('errmsg')}")
return False
except Exception as e:
logger.error(f"钉钉消息发送异常: {str(e)}")
return False
class WeworkChannel(NotificationChannel):
"""
企业微信通知渠道
使用企业微信应用消息 API
文档: https://developer.work.weixin.qq.com/document/path/90236
"""
def __init__(
self,
corp_id: Optional[str] = None,
corp_secret: Optional[str] = None,
agent_id: Optional[str] = None,
):
self.corp_id = corp_id or os.getenv("WEWORK_CORP_ID")
self.corp_secret = corp_secret or os.getenv("WEWORK_CORP_SECRET")
self.agent_id = agent_id or os.getenv("WEWORK_AGENT_ID")
self._access_token = None
self._token_expires_at = None
async def _get_access_token(self) -> str:
"""获取企业微信访问令牌"""
if (
self._access_token
and self._token_expires_at
and datetime.now() < self._token_expires_at
):
return self._access_token
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.corp_secret,
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
self._access_token = result["access_token"]
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
return self._access_token
else:
raise Exception(f"获取企微Token失败: {result.get('errmsg')}")
async def send(
self,
user_id: int,
title: str,
content: str,
wework_user_id: Optional[str] = None,
**kwargs
) -> bool:
"""发送企业微信应用消息"""
if not all([self.corp_id, self.corp_secret, self.agent_id]):
logger.warning("企业微信配置不完整,跳过发送")
return False
if not wework_user_id:
logger.warning(f"用户 {user_id} 没有绑定企业微信ID")
return False
try:
access_token = await self._get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
# 构建消息体
msg = {
"touser": wework_user_id,
"msgtype": "text",
"agentid": int(self.agent_id),
"text": {
"content": f"{title}\n\n{content}"
}
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=msg, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
logger.info(f"企微消息发送成功: user_id={user_id}")
return True
else:
logger.error(f"企微消息发送失败: {result.get('errmsg')}")
return False
except Exception as e:
logger.error(f"企微消息发送异常: {str(e)}")
return False
class InAppChannel(NotificationChannel):
"""站内消息通道"""
def __init__(self, db: AsyncSession):
self.db = db
async def send(
self,
user_id: int,
title: str,
content: str,
notification_type: str = "system",
**kwargs
) -> bool:
"""创建站内消息"""
try:
notification = Notification(
user_id=user_id,
title=title,
content=content,
type=notification_type,
is_read=False,
)
self.db.add(notification)
await self.db.commit()
logger.info(f"站内消息创建成功: user_id={user_id}")
return True
except Exception as e:
logger.error(f"站内消息创建失败: {str(e)}")
return False
class NotificationService:
"""
通知服务
统一管理多渠道通知发送
"""
def __init__(self, db: AsyncSession):
self.db = db
self.channels = {
"dingtalk": DingtalkChannel(),
"wework": WeworkChannel(),
"inapp": InAppChannel(db),
}
async def send_notification(
self,
user_id: int,
title: str,
content: str,
channels: Optional[List[str]] = None,
**kwargs
) -> Dict[str, bool]:
"""
发送通知
Args:
user_id: 用户ID
title: 通知标题
content: 通知内容
channels: 发送渠道列表,默认全部发送
Returns:
各渠道发送结果
"""
# 获取用户信息
user = await self._get_user(user_id)
if not user:
return {"error": "用户不存在"}
# 准备用户渠道标识
user_channels = {
"dingtalk_user_id": getattr(user, "dingtalk_id", None),
"wework_user_id": getattr(user, "wework_userid", None),
}
# 确定发送渠道
target_channels = channels or ["inapp"] # 默认只发站内消息
results = {}
for channel_name in target_channels:
if channel_name in self.channels:
channel = self.channels[channel_name]
success = await channel.send(
user_id=user_id,
title=title,
content=content,
**user_channels,
**kwargs
)
results[channel_name] = success
return results
async def send_learning_reminder(
self,
user_id: int,
course_name: str,
days_inactive: int = 3,
) -> Dict[str, bool]:
"""发送学习提醒"""
title = "📚 学习提醒"
content = f"您已有 {days_inactive} 天没有学习《{course_name}》课程了,快来继续学习吧!"
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="learning_reminder",
)
async def send_task_deadline_reminder(
self,
user_id: int,
task_name: str,
deadline: datetime,
) -> Dict[str, bool]:
"""发送任务截止提醒"""
days_left = (deadline - datetime.now()).days
title = "⏰ 任务截止提醒"
content = f"任务《{task_name}》将于 {deadline.strftime('%Y-%m-%d %H:%M')} 截止,还有 {days_left} 天,请尽快完成!"
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="task_deadline",
)
async def send_exam_reminder(
self,
user_id: int,
exam_name: str,
exam_time: datetime,
) -> Dict[str, bool]:
"""发送考试提醒"""
title = "📝 考试提醒"
content = f"考试《{exam_name}》将于 {exam_time.strftime('%Y-%m-%d %H:%M')} 开始,请提前做好准备!"
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="exam_reminder",
)
async def send_weekly_report(
self,
user_id: int,
study_time: int,
courses_completed: int,
exams_passed: int,
) -> Dict[str, bool]:
"""发送周学习报告"""
title = "📊 本周学习报告"
content = (
f"本周学习总结:\n"
f"• 学习时长:{study_time // 60} 分钟\n"
f"• 完成课程:{courses_completed}\n"
f"• 通过考试:{exams_passed}\n\n"
f"继续加油!💪"
)
result = await db.execute(stmt)
notification = result.scalar_one_or_none()
if notification:
await db.delete(notification)
await db.commit()
logger.info(
"删除通知成功",
notification_id=notification_id,
user_id=user_id
)
return True
return False
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="weekly_report",
)
async def _get_user(self, user_id: int) -> Optional[User]:
"""获取用户信息"""
result = await self.db.execute(
select(User).where(User.id == user_id)
)
return result.scalar_one_or_none()
# 创建服务实例
notification_service = NotificationService()
# 便捷函数
def get_notification_service(db: AsyncSession) -> NotificationService:
"""获取通知服务实例"""
return NotificationService(db)

View File

@@ -0,0 +1,151 @@
"""
权限检查服务
"""
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.models.user import User
from app.models.position import Position
from app.models.position_member import PositionMember
from app.models.position_course import PositionCourse
from app.models.course import Course, CourseStatus
class PermissionService:
"""权限检查服务类"""
def __init__(self, db: AsyncSession):
self.db = db
async def check_team_membership(self, user_id: int, team_id: int) -> bool:
"""
检查用户是否属于指定团队(岗位)
"""
result = await self.db.execute(
select(PositionMember).where(
and_(
PositionMember.user_id == user_id,
PositionMember.position_id == team_id,
)
)
)
return result.scalar_one_or_none() is not None
async def check_course_access(self, user_id: int, course_id: int) -> bool:
"""
检查用户是否可以访问指定课程
规则:
1. 课程必须是已发布状态
2. 课程必须分配给用户所在的某个岗位
"""
# 获取课程信息
course_result = await self.db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
return False
# 草稿状态的课程只有管理员可以访问
if course.status != CourseStatus.PUBLISHED:
return False
# 获取用户所在的所有岗位
positions_result = await self.db.execute(
select(PositionMember.position_id).where(
PositionMember.user_id == user_id
)
)
user_position_ids = [row[0] for row in positions_result.all()]
if not user_position_ids:
# 没有岗位的用户可以访问所有已发布课程(基础学习权限)
return True
# 检查课程是否分配给用户的任一岗位
course_position_result = await self.db.execute(
select(PositionCourse).where(
and_(
PositionCourse.course_id == course_id,
PositionCourse.position_id.in_(user_position_ids),
)
)
)
has_position_access = course_position_result.scalar_one_or_none() is not None
# 如果没有通过岗位分配访问,仍然允许访问已发布的公开课程
# 这是为了确保所有用户都能看到公开课程
return has_position_access or True # 暂时允许所有已发布课程
async def get_user_accessible_courses(self, user_id: int) -> List[int]:
"""
获取用户可访问的所有课程ID
"""
# 获取用户所在的所有岗位
positions_result = await self.db.execute(
select(PositionMember.position_id).where(
PositionMember.user_id == user_id
)
)
user_position_ids = [row[0] for row in positions_result.all()]
if not user_position_ids:
# 没有岗位的用户返回所有已发布课程
courses_result = await self.db.execute(
select(Course.id).where(Course.status == CourseStatus.PUBLISHED)
)
return [row[0] for row in courses_result.all()]
# 获取岗位分配的课程
courses_result = await self.db.execute(
select(PositionCourse.course_id).where(
PositionCourse.position_id.in_(user_position_ids)
).distinct()
)
return [row[0] for row in courses_result.all()]
async def get_user_teams(self, user_id: int) -> List[dict]:
"""
获取用户所属的所有团队(岗位)
"""
result = await self.db.execute(
select(Position).join(
PositionMember, PositionMember.position_id == Position.id
).where(
PositionMember.user_id == user_id
)
)
positions = result.scalars().all()
return [{"id": p.id, "name": p.name} for p in positions]
async def is_team_manager(self, user_id: int, team_id: int) -> bool:
"""
检查用户是否是团队管理者
"""
# 检查用户是否是该岗位的创建者或管理者
position_result = await self.db.execute(
select(Position).where(Position.id == team_id)
)
position = position_result.scalar_one_or_none()
if not position:
return False
# 检查创建者
if hasattr(position, 'created_by') and position.created_by == user_id:
return True
# 检查用户角色是否为管理者
user_result = await self.db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
return user and user.role in ['admin', 'manager']
# 辅助函数:创建权限服务实例
def get_permission_service(db: AsyncSession) -> PermissionService:
return PermissionService(db)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,379 @@
"""
智能学习推荐服务
基于用户能力评估、错题记录和学习历史推荐学习内容
"""
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func, desc
from sqlalchemy.orm import selectinload
from app.models.user import User
from app.models.course import Course, CourseStatus, CourseMaterial, KnowledgePoint
from app.models.exam import ExamResult
from app.models.exam_mistake import ExamMistake
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.models.ability import AbilityAssessment
logger = logging.getLogger(__name__)
class RecommendationService:
"""
智能学习推荐服务
推荐策略:
1. 基于错题分析:推荐与错题相关的知识点和课程
2. 基于能力评估:推荐弱项能力相关的课程
3. 基于学习进度:推荐未完成的课程继续学习
4. 基于热门课程:推荐学习人数多的课程
5. 基于岗位要求:推荐岗位必修课程
"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_recommendations(
self,
user_id: int,
limit: int = 10,
include_reasons: bool = True,
) -> List[Dict[str, Any]]:
"""
获取个性化学习推荐
Args:
user_id: 用户ID
limit: 推荐数量上限
include_reasons: 是否包含推荐理由
Returns:
推荐课程列表,包含课程信息和推荐理由
"""
recommendations = []
# 1. 基于错题推荐
mistake_recs = await self._get_mistake_based_recommendations(user_id)
recommendations.extend(mistake_recs)
# 2. 基于能力评估推荐
ability_recs = await self._get_ability_based_recommendations(user_id)
recommendations.extend(ability_recs)
# 3. 基于未完成课程推荐
progress_recs = await self._get_progress_based_recommendations(user_id)
recommendations.extend(progress_recs)
# 4. 基于热门课程推荐
popular_recs = await self._get_popular_recommendations(user_id)
recommendations.extend(popular_recs)
# 去重并排序
seen_course_ids = set()
unique_recs = []
for rec in recommendations:
if rec["course_id"] not in seen_course_ids:
seen_course_ids.add(rec["course_id"])
unique_recs.append(rec)
# 按优先级排序
priority_map = {
"mistake": 1,
"ability": 2,
"progress": 3,
"popular": 4,
}
unique_recs.sort(key=lambda x: priority_map.get(x.get("source", ""), 5))
# 限制数量
result = unique_recs[:limit]
# 移除 source 字段如果不需要理由
if not include_reasons:
for rec in result:
rec.pop("source", None)
rec.pop("reason", None)
return result
async def _get_mistake_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于错题推荐"""
recommendations = []
try:
# 获取用户最近的错题
result = await self.db.execute(
select(ExamMistake).where(
ExamMistake.user_id == user_id
).order_by(
desc(ExamMistake.created_at)
).limit(50)
)
mistakes = result.scalars().all()
if not mistakes:
return recommendations
# 统计错题涉及的知识点
knowledge_point_counts = {}
for mistake in mistakes:
if hasattr(mistake, 'knowledge_point_id') and mistake.knowledge_point_id:
kp_id = mistake.knowledge_point_id
knowledge_point_counts[kp_id] = knowledge_point_counts.get(kp_id, 0) + 1
if not knowledge_point_counts:
return recommendations
# 找出错误最多的知识点对应的课程
top_kp_ids = sorted(
knowledge_point_counts.keys(),
key=lambda x: knowledge_point_counts[x],
reverse=True
)[:5]
course_result = await self.db.execute(
select(Course, KnowledgePoint).join(
KnowledgePoint, Course.id == KnowledgePoint.course_id
).where(
and_(
KnowledgePoint.id.in_(top_kp_ids),
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).distinct()
)
for course, kp in course_result.all()[:limit]:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"source": "mistake",
"reason": f"您在「{kp.name}」知识点上有错题,建议复习相关内容",
})
except Exception as e:
logger.error(f"基于错题推荐失败: {str(e)}")
return recommendations
async def _get_ability_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于能力评估推荐"""
recommendations = []
try:
# 获取用户最近的能力评估
result = await self.db.execute(
select(AbilityAssessment).where(
AbilityAssessment.user_id == user_id
).order_by(
desc(AbilityAssessment.created_at)
).limit(1)
)
assessment = result.scalar_one_or_none()
if not assessment:
return recommendations
# 解析能力评估结果,找出弱项
scores = {}
if hasattr(assessment, 'dimension_scores') and assessment.dimension_scores:
scores = assessment.dimension_scores
elif hasattr(assessment, 'scores') and assessment.scores:
scores = assessment.scores
if not scores:
return recommendations
# 找出分数最低的维度
weak_dimensions = sorted(
scores.items(),
key=lambda x: x[1] if isinstance(x[1], (int, float)) else 0
)[:3]
# 根据弱项维度推荐课程(按分类匹配)
category_map = {
"专业知识": "technology",
"沟通能力": "business",
"管理能力": "management",
}
for dim_name, score in weak_dimensions:
if isinstance(score, (int, float)) and score < 70:
category = category_map.get(dim_name)
if category:
course_result = await self.db.execute(
select(Course).where(
and_(
Course.category == category,
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).order_by(
desc(Course.student_count)
).limit(1)
)
course = course_result.scalar_one_or_none()
if course:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"source": "ability",
"reason": f"您的「{dim_name}」能力评分较低({score}分),推荐学习此课程提升",
})
except Exception as e:
logger.error(f"基于能力评估推荐失败: {str(e)}")
return recommendations[:limit]
async def _get_progress_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于学习进度推荐"""
recommendations = []
try:
# 获取未完成的课程
result = await self.db.execute(
select(UserCourseProgress, Course).join(
Course, UserCourseProgress.course_id == Course.id
).where(
and_(
UserCourseProgress.user_id == user_id,
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
Course.is_deleted == False,
)
).order_by(
desc(UserCourseProgress.last_accessed_at)
).limit(limit)
)
for progress, course in result.all():
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"progress_percent": progress.progress_percent,
"source": "progress",
"reason": f"继续学习,已完成 {progress.progress_percent:.0f}%",
})
except Exception as e:
logger.error(f"基于进度推荐失败: {str(e)}")
return recommendations
async def _get_popular_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于热门课程推荐"""
recommendations = []
try:
# 获取用户已学习的课程ID
learned_result = await self.db.execute(
select(UserCourseProgress.course_id).where(
UserCourseProgress.user_id == user_id
)
)
learned_course_ids = [row[0] for row in learned_result.all()]
# 获取热门课程(排除已学习的)
query = select(Course).where(
and_(
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).order_by(
desc(Course.student_count)
).limit(limit + len(learned_course_ids))
result = await self.db.execute(query)
courses = result.scalars().all()
for course in courses:
if course.id not in learned_course_ids:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"student_count": course.student_count,
"source": "popular",
"reason": f"热门课程,已有 {course.student_count} 人学习",
})
if len(recommendations) >= limit:
break
except Exception as e:
logger.error(f"基于热门推荐失败: {str(e)}")
return recommendations
async def get_knowledge_point_recommendations(
self,
user_id: int,
limit: int = 5,
) -> List[Dict[str, Any]]:
"""
获取知识点级别的推荐
基于错题和能力评估推荐具体的知识点
"""
recommendations = []
try:
# 获取错题涉及的知识点
mistake_result = await self.db.execute(
select(
KnowledgePoint,
func.count(ExamMistake.id).label('mistake_count')
).join(
ExamMistake,
ExamMistake.knowledge_point_id == KnowledgePoint.id
).where(
ExamMistake.user_id == user_id
).group_by(
KnowledgePoint.id
).order_by(
desc('mistake_count')
).limit(limit)
)
for kp, count in mistake_result.all():
recommendations.append({
"knowledge_point_id": kp.id,
"name": kp.name,
"description": kp.description,
"type": kp.type,
"course_id": kp.course_id,
"mistake_count": count,
"reason": f"您在此知识点有 {count} 道错题,建议重点复习",
})
except Exception as e:
logger.error(f"知识点推荐失败: {str(e)}")
return recommendations
# 便捷函数
def get_recommendation_service(db: AsyncSession) -> RecommendationService:
"""获取推荐服务实例"""
return RecommendationService(db)

View File

@@ -0,0 +1,273 @@
"""
定时任务服务
使用 APScheduler 管理定时任务
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, and_, func
from app.core.config import settings
from app.models.user import User
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.models.task import Task, TaskAssignment
logger = logging.getLogger(__name__)
# 全局调度器实例
scheduler: Optional[AsyncIOScheduler] = None
async def get_db_session() -> AsyncSession:
"""获取数据库会话"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
return async_session()
async def send_learning_reminders():
"""
发送学习提醒
检查所有用户的学习进度,对长时间未学习的用户发送提醒
"""
logger.info("开始执行学习提醒任务")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 查找超过3天未学习的用户
three_days_ago = datetime.now() - timedelta(days=3)
result = await db.execute(
select(UserCourseProgress, User).join(
User, UserCourseProgress.user_id == User.id
).where(
and_(
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
UserCourseProgress.last_accessed_at < three_days_ago,
)
)
)
inactive_progress = result.all()
for progress, user in inactive_progress:
# 获取课程名称
from app.models.course import Course
course_result = await db.execute(
select(Course.name).where(Course.id == progress.course_id)
)
course_name = course_result.scalar() or "未知课程"
days_inactive = (datetime.now() - progress.last_accessed_at).days
# 发送提醒
await notification_service.send_learning_reminder(
user_id=user.id,
course_name=course_name,
days_inactive=days_inactive,
)
logger.info(f"已发送学习提醒: user_id={user.id}, course={course_name}")
await db.close()
logger.info(f"学习提醒任务完成,发送了 {len(inactive_progress)} 条提醒")
except Exception as e:
logger.error(f"学习提醒任务失败: {str(e)}")
async def send_task_deadline_reminders():
"""
发送任务截止提醒
检查即将到期的任务,发送提醒给相关用户
"""
logger.info("开始执行任务截止提醒")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 查找3天内到期的未完成任务
now = datetime.now()
three_days_later = now + timedelta(days=3)
result = await db.execute(
select(Task, TaskAssignment, User).join(
TaskAssignment, Task.id == TaskAssignment.task_id
).join(
User, TaskAssignment.user_id == User.id
).where(
and_(
Task.end_time.between(now, three_days_later),
TaskAssignment.status.in_(["not_started", "in_progress"]),
)
)
)
upcoming_tasks = result.all()
for task, assignment, user in upcoming_tasks:
await notification_service.send_task_deadline_reminder(
user_id=user.id,
task_name=task.name,
deadline=task.end_time,
)
logger.info(f"已发送任务截止提醒: user_id={user.id}, task={task.name}")
await db.close()
logger.info(f"任务截止提醒完成,发送了 {len(upcoming_tasks)} 条提醒")
except Exception as e:
logger.error(f"任务截止提醒失败: {str(e)}")
async def send_weekly_reports():
"""
发送周学习报告
每周一发送上周的学习统计报告
"""
logger.info("开始生成周学习报告")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 获取所有活跃用户
result = await db.execute(
select(User).where(User.is_active == True)
)
users = result.scalars().all()
# 计算上周时间范围
today = datetime.now().date()
last_week_start = today - timedelta(days=today.weekday() + 7)
last_week_end = last_week_start + timedelta(days=6)
for user in users:
# 统计学习时长
study_time_result = await db.execute(
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0)).where(
and_(
UserCourseProgress.user_id == user.id,
UserCourseProgress.last_accessed_at.between(
datetime.combine(last_week_start, datetime.min.time()),
datetime.combine(last_week_end, datetime.max.time()),
)
)
)
)
study_time = study_time_result.scalar() or 0
# 统计完成课程数
completed_result = await db.execute(
select(func.count(UserCourseProgress.id)).where(
and_(
UserCourseProgress.user_id == user.id,
UserCourseProgress.status == ProgressStatus.COMPLETED.value,
UserCourseProgress.completed_at.between(
datetime.combine(last_week_start, datetime.min.time()),
datetime.combine(last_week_end, datetime.max.time()),
)
)
)
)
courses_completed = completed_result.scalar() or 0
# 如果有学习活动,发送报告
if study_time > 0 or courses_completed > 0:
await notification_service.send_weekly_report(
user_id=user.id,
study_time=study_time,
courses_completed=courses_completed,
exams_passed=0, # TODO: 统计考试通过数
)
logger.info(f"已发送周报: user_id={user.id}")
await db.close()
logger.info("周学习报告发送完成")
except Exception as e:
logger.error(f"周学习报告发送失败: {str(e)}")
def init_scheduler():
"""初始化定时任务调度器"""
global scheduler
if scheduler is not None:
return scheduler
scheduler = AsyncIOScheduler()
# 学习提醒每天上午9点执行
scheduler.add_job(
send_learning_reminders,
CronTrigger(hour=9, minute=0),
id="learning_reminders",
name="学习提醒",
replace_existing=True,
)
# 任务截止提醒每天上午10点执行
scheduler.add_job(
send_task_deadline_reminders,
CronTrigger(hour=10, minute=0),
id="task_deadline_reminders",
name="任务截止提醒",
replace_existing=True,
)
# 周学习报告每周一上午8点发送
scheduler.add_job(
send_weekly_reports,
CronTrigger(day_of_week="mon", hour=8, minute=0),
id="weekly_reports",
name="周学习报告",
replace_existing=True,
)
logger.info("定时任务调度器初始化完成")
return scheduler
def start_scheduler():
"""启动调度器"""
global scheduler
if scheduler is None:
scheduler = init_scheduler()
if not scheduler.running:
scheduler.start()
logger.info("定时任务调度器已启动")
def stop_scheduler():
"""停止调度器"""
global scheduler
if scheduler and scheduler.running:
scheduler.shutdown()
logger.info("定时任务调度器已停止")
def get_scheduler() -> Optional[AsyncIOScheduler]:
"""获取调度器实例"""
return scheduler

View File

@@ -0,0 +1,256 @@
"""
语音识别服务
支持多种语音识别引擎:
1. 阿里云语音识别
2. 讯飞语音识别
3. 本地 Whisper 模型
"""
import os
import base64
import json
import hmac
import hashlib
import time
from datetime import datetime
from typing import Optional, Dict, Any
import httpx
from urllib.parse import urlencode
class SpeechRecognitionError(Exception):
"""语音识别错误"""
pass
class AliyunSpeechRecognition:
"""
阿里云智能语音交互 - 一句话识别
文档: https://help.aliyun.com/document_detail/92131.html
"""
def __init__(
self,
access_key_id: Optional[str] = None,
access_key_secret: Optional[str] = None,
app_key: Optional[str] = None,
):
self.access_key_id = access_key_id or os.getenv("ALIYUN_ACCESS_KEY_ID")
self.access_key_secret = access_key_secret or os.getenv("ALIYUN_ACCESS_KEY_SECRET")
self.app_key = app_key or os.getenv("ALIYUN_NLS_APP_KEY")
self.api_url = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
def _create_signature(self, params: Dict[str, str]) -> str:
"""创建签名"""
sorted_params = sorted(params.items())
query_string = urlencode(sorted_params)
string_to_sign = f"POST&%2F&{urlencode({query_string: ''}).split('=')[0]}"
signature = hmac.new(
(self.access_key_secret + "&").encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(signature).decode("utf-8")
async def recognize(
self,
audio_data: bytes,
format: str = "wav",
sample_rate: int = 16000,
) -> str:
"""
识别音频
Args:
audio_data: 音频数据(二进制)
format: 音频格式,支持 pcm, wav, ogg, opus, mp3
sample_rate: 采样率,默认 16000
Returns:
识别出的文本
"""
if not all([self.access_key_id, self.access_key_secret, self.app_key]):
raise SpeechRecognitionError("阿里云语音识别配置不完整")
headers = {
"Content-Type": f"audio/{format}; samplerate={sample_rate}",
"X-NLS-Token": await self._get_token(),
}
params = {
"appkey": self.app_key,
"format": format,
"sample_rate": str(sample_rate),
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self.api_url,
params=params,
headers=headers,
content=audio_data,
timeout=30.0,
)
if response.status_code != 200:
raise SpeechRecognitionError(
f"阿里云语音识别请求失败: {response.status_code}"
)
result = response.json()
if result.get("status") == 20000000:
return result.get("result", "")
else:
raise SpeechRecognitionError(
f"语音识别失败: {result.get('message', '未知错误')}"
)
except httpx.RequestError as e:
raise SpeechRecognitionError(f"网络请求错误: {str(e)}")
async def _get_token(self) -> str:
"""获取访问令牌"""
# 简化版:实际生产环境需要缓存 token
token_url = "https://nls-meta.cn-shanghai.aliyuncs.com/"
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
params = {
"AccessKeyId": self.access_key_id,
"Action": "CreateToken",
"Format": "JSON",
"RegionId": "cn-shanghai",
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(int(time.time() * 1000)),
"SignatureVersion": "1.0",
"Timestamp": timestamp,
"Version": "2019-02-28",
}
params["Signature"] = self._create_signature(params)
async with httpx.AsyncClient() as client:
response = await client.get(token_url, params=params, timeout=10.0)
result = response.json()
if "Token" in result:
return result["Token"]["Id"]
else:
raise SpeechRecognitionError(
f"获取阿里云语音识别 Token 失败: {result.get('Message', '未知错误')}"
)
class XunfeiSpeechRecognition:
"""
讯飞语音识别
文档: https://www.xfyun.cn/doc/asr/voicedictation/API.html
"""
def __init__(
self,
app_id: Optional[str] = None,
api_key: Optional[str] = None,
api_secret: Optional[str] = None,
):
self.app_id = app_id or os.getenv("XUNFEI_APP_ID")
self.api_key = api_key or os.getenv("XUNFEI_API_KEY")
self.api_secret = api_secret or os.getenv("XUNFEI_API_SECRET")
self.api_url = "wss://iat-api.xfyun.cn/v2/iat"
async def recognize(
self,
audio_data: bytes,
format: str = "audio/L16;rate=16000",
) -> str:
"""
识别音频
Args:
audio_data: 音频数据(二进制)
format: 音频格式
Returns:
识别出的文本
"""
if not all([self.app_id, self.api_key, self.api_secret]):
raise SpeechRecognitionError("讯飞语音识别配置不完整")
# 讯飞使用 WebSocket这里是简化实现
# 实际需要使用 websockets 库进行实时流式识别
raise NotImplementedError("讯飞语音识别需要 WebSocket 实现")
class SimpleSpeechRecognition:
"""
简易语音识别实现
使用浏览器 Web Speech API 的结果直接返回
用于前端已经完成识别的情况
"""
async def recognize(self, text: str) -> str:
"""直接返回前端传来的识别结果"""
return text.strip()
class SpeechRecognitionService:
"""
语音识别服务统一接口
根据配置选择不同的识别引擎
"""
def __init__(self, engine: str = "simple"):
"""
初始化语音识别服务
Args:
engine: 识别引擎,支持 aliyun, xunfei, simple
"""
self.engine = engine
if engine == "aliyun":
self._recognizer = AliyunSpeechRecognition()
elif engine == "xunfei":
self._recognizer = XunfeiSpeechRecognition()
else:
self._recognizer = SimpleSpeechRecognition()
async def recognize_audio(
self,
audio_data: bytes,
format: str = "wav",
sample_rate: int = 16000,
) -> str:
"""
识别音频数据
Args:
audio_data: 音频二进制数据
format: 音频格式
sample_rate: 采样率
Returns:
识别出的文本
"""
if self.engine == "simple":
raise SpeechRecognitionError(
"简易模式不支持音频识别,请使用前端 Web Speech API"
)
return await self._recognizer.recognize(audio_data, format, sample_rate)
async def recognize_text(self, text: str) -> str:
"""
直接处理已识别的文本(用于前端已完成识别的情况)
Args:
text: 已识别的文本
Returns:
处理后的文本
"""
return text.strip()
# 创建默认服务实例
def get_speech_recognition_service(engine: str = "simple") -> SpeechRecognitionService:
"""获取语音识别服务实例"""
return SpeechRecognitionService(engine=engine)