Files
012-kaopeilian/backend/app/services/notification_service.py
yuliang_guo 64f5d567fa
Some checks failed
continuous-integration/drone/push Build is failing
feat: 实现 KPL 系统功能改进计划
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 格式
2026-01-30 14:22:35 +08:00

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)