feat: 实现 KPL 系统功能改进计划
Some checks failed
continuous-integration/drone/push Build is failing

1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

View File

@@ -1,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)