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 格式
303 lines
9.9 KiB
Python
303 lines
9.9 KiB
Python
"""
|
||
钉钉认证服务
|
||
|
||
提供钉钉免密登录功能,从数据库读取配置
|
||
"""
|
||
|
||
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"),
|
||
}
|