Some checks failed
continuous-integration/drone/push Build is failing
1. 课程学习进度追踪
- 新增 UserCourseProgress 和 UserMaterialProgress 模型
- 新增 /api/v1/progress/* 进度追踪 API
- 更新 admin.py 使用真实课程完成率数据
2. 路由权限检查完善
- 新增前端 permissionChecker.ts 权限检查工具
- 更新 router/guard.ts 实现团队和课程权限验证
- 新增后端 permission_service.py
3. AI 陪练音频转文本
- 新增 speech_recognition.py 语音识别服务
- 新增 /api/v1/speech/* API
- 更新 ai-practice-coze.vue 支持语音输入
4. 双人对练报告生成
- 更新 practice_room_service.py 添加报告生成功能
- 新增 /rooms/{room_code}/report API
- 更新 duo-practice-report.vue 调用真实 API
5. 学习提醒推送
- 新增 notification_service.py 通知服务
- 新增 scheduler_service.py 定时任务服务
- 支持钉钉、企微、站内消息推送
6. 智能学习推荐
- 新增 recommendation_service.py 推荐服务
- 新增 /api/v1/recommendations/* API
- 支持错题、能力、进度、热门多维度推荐
7. 安全问题修复
- DEBUG 默认值改为 False
- 添加 SECRET_KEY 安全警告
- 新增 check_security_settings() 检查函数
8. 证书 PDF 生成
- 更新 certificate_service.py 添加 PDF 生成
- 添加 weasyprint、Pillow、qrcode 依赖
- 更新下载 API 支持 PDF 和 PNG 格式
420 lines
13 KiB
Python
420 lines
13 KiB
Python
"""
|
|
通知推送服务
|
|
支持钉钉、企业微信、站内消息等多种渠道
|
|
"""
|
|
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.models.user import User
|
|
from app.models.notification import Notification
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NotificationChannel:
|
|
"""通知渠道基类"""
|
|
|
|
async def send(
|
|
self,
|
|
user_id: int,
|
|
title: str,
|
|
content: str,
|
|
**kwargs
|
|
) -> bool:
|
|
"""
|
|
发送通知
|
|
|
|
Args:
|
|
user_id: 用户ID
|
|
title: 通知标题
|
|
content: 通知内容
|
|
|
|
Returns:
|
|
是否发送成功
|
|
"""
|
|
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"继续加油!💪"
|
|
)
|
|
|
|
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()
|
|
|
|
|
|
# 便捷函数
|
|
def get_notification_service(db: AsyncSession) -> NotificationService:
|
|
"""获取通知服务实例"""
|
|
return NotificationService(db)
|