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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
151
backend/app/services/permission_service.py
Normal file
151
backend/app/services/permission_service.py
Normal 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
379
backend/app/services/recommendation_service.py
Normal file
379
backend/app/services/recommendation_service.py
Normal 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)
|
||||
273
backend/app/services/scheduler_service.py
Normal file
273
backend/app/services/scheduler_service.py
Normal 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
|
||||
256
backend/app/services/speech_recognition.py
Normal file
256
backend/app/services/speech_recognition.py
Normal 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)
|
||||
Reference in New Issue
Block a user