feat: 添加钉钉扫码登录功能
Some checks failed
continuous-integration/drone/push Build is failing

- 后端:钉钉 OAuth 认证服务
- 后端:系统设置 API(钉钉配置)
- 前端:登录页钉钉扫码入口
- 前端:系统设置页面
- 数据库迁移脚本
This commit is contained in:
yuliang_guo
2026-01-29 14:40:00 +08:00
parent c5d460b413
commit 662947cd06
16 changed files with 1417 additions and 9 deletions

View File

@@ -104,5 +104,8 @@ api_router.include_router(notifications_router, tags=["notifications"])
api_router.include_router(scrm_router, tags=["scrm"]) api_router.include_router(scrm_router, tags=["scrm"])
# admin_portal_router SaaS超级管理后台路由prefix在router内部定义为/admin # admin_portal_router SaaS超级管理后台路由prefix在router内部定义为/admin
api_router.include_router(admin_portal_router, tags=["admin-portal"]) api_router.include_router(admin_portal_router, tags=["admin-portal"])
# system_settings_router 系统设置路由(企业管理员配置)
from .system_settings import router as system_settings_router
api_router.include_router(system_settings_router, prefix="/settings", tags=["system-settings"])
__all__ = ["api_router"] __all__ = ["api_router"]

View File

@@ -32,6 +32,7 @@ CONFIG_GROUP_NAMES = {
"yanji": "言迹工牌配置", "yanji": "言迹工牌配置",
"storage": "文件存储配置", "storage": "文件存储配置",
"basic": "基础配置", "basic": "基础配置",
"dingtalk": "钉钉配置",
} }

View File

@@ -27,6 +27,7 @@ FEATURE_GROUP_NAMES = {
"broadcast": "播课模块", "broadcast": "播课模块",
"course": "课程模块", "course": "课程模块",
"yanji": "智能工牌模块", "yanji": "智能工牌模块",
"auth": "认证模块",
} }

View File

@@ -1,16 +1,17 @@
""" """
认证 API 认证 API
""" """
from fastapi import APIRouter, Depends, status, Request from fastapi import APIRouter, Depends, status, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_active_user, get_db from app.core.deps import get_current_active_user, get_db
from app.core.logger import logger from app.core.logger import logger
from app.models.user import User from app.models.user import User
from app.schemas.auth import LoginRequest, RefreshTokenRequest, Token from app.schemas.auth import LoginRequest, RefreshTokenRequest, Token, DingtalkLoginRequest
from app.schemas.base import ResponseModel from app.schemas.base import ResponseModel
from app.schemas.user import User as UserSchema from app.schemas.user import User as UserSchema
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.dingtalk_auth_service import DingtalkAuthService
from app.services.system_log_service import system_log_service from app.services.system_log_service import system_log_service
from app.schemas.system_log import SystemLogCreate from app.schemas.system_log import SystemLogCreate
from app.core.exceptions import UnauthorizedError from app.core.exceptions import UnauthorizedError
@@ -154,3 +155,102 @@ async def verify_token(
"user": UserSchema.model_validate(current_user).model_dump(), "user": UserSchema.model_validate(current_user).model_dump(),
}, },
) )
# ============================================
# 钉钉免密登录 API
# ============================================
@router.post("/dingtalk/login", response_model=ResponseModel)
async def dingtalk_login(
login_data: DingtalkLoginRequest,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
钉钉免密登录
通过钉钉免登授权码登录系统
"""
dingtalk_service = DingtalkAuthService(db)
try:
user, token = await dingtalk_service.dingtalk_login(
tenant_id=login_data.tenant_id,
code=login_data.code,
)
# 记录登录成功日志
await system_log_service.create_log(
db,
SystemLogCreate(
level="INFO",
type="security",
message=f"用户 {user.username} 通过钉钉免密登录成功",
user_id=user.id,
user=user.username,
ip=request.client.host if request.client else None,
path="/api/v1/auth/dingtalk/login",
method="POST",
user_agent=request.headers.get("user-agent")
)
)
return ResponseModel(
message="钉钉登录成功",
data={
"user": UserSchema.model_validate(user).model_dump(),
"token": token.model_dump(),
},
)
except Exception as e:
error_msg = str(e)
logger.warning("dingtalk_login_failed", error=error_msg)
# 记录登录失败日志
await system_log_service.create_log(
db,
SystemLogCreate(
level="WARNING",
type="security",
message=f"钉钉免密登录失败:{error_msg}",
ip=request.client.host if request.client else None,
path="/api/v1/auth/dingtalk/login",
method="POST",
user_agent=request.headers.get("user-agent")
)
)
return ResponseModel(
code=400,
message=error_msg,
data=None,
)
@router.get("/dingtalk/config", response_model=ResponseModel)
async def get_dingtalk_config(
tenant_id: int = Query(default=1, description="租户ID"),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取钉钉公开配置
前端需要使用 corpId 和 agentId 初始化钉钉JSDK
仅返回非敏感信息
"""
dingtalk_service = DingtalkAuthService(db)
try:
config = await dingtalk_service.get_public_config(tenant_id)
return ResponseModel(
message="获取成功",
data=config,
)
except Exception as e:
logger.error("get_dingtalk_config_failed", error=str(e))
return ResponseModel(
code=500,
message="获取钉钉配置失败",
data={"enabled": False, "corp_id": None, "agent_id": None},
)

View File

@@ -0,0 +1,306 @@
"""
系统设置 API
供企业管理员admin角色配置系统级别的设置如钉钉免密登录等
"""
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from app.core.deps import get_current_active_user, get_db
from app.core.logger import logger
from app.models.user import User
from app.schemas.base import ResponseModel
router = APIRouter()
# ============================================
# Schema 定义
# ============================================
class DingtalkConfigUpdate(BaseModel):
"""钉钉配置更新请求"""
app_key: Optional[str] = Field(None, description="钉钉AppKey")
app_secret: Optional[str] = Field(None, description="钉钉AppSecret")
agent_id: Optional[str] = Field(None, description="钉钉AgentId")
corp_id: Optional[str] = Field(None, description="钉钉CorpId")
enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录")
class DingtalkConfigResponse(BaseModel):
"""钉钉配置响应"""
app_key: Optional[str] = None
app_secret_masked: Optional[str] = None # 脱敏显示
agent_id: Optional[str] = None
corp_id: Optional[str] = None
enabled: bool = False
# ============================================
# 辅助函数
# ============================================
def check_admin_permission(user: User):
"""检查是否为管理员"""
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
async def get_or_create_tenant_id(db: AsyncSession) -> int:
"""获取或创建默认租户ID简化版假设单租户"""
# 对于考培练系统简化处理使用固定的租户ID=1
return 1
async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]:
"""获取系统配置值"""
result = await db.execute(
text("""
SELECT config_value FROM tenant_configs
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
"""),
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key}
)
row = result.fetchone()
return row[0] if row else None
async def set_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str, config_value: str):
"""设置系统配置值"""
# 检查是否已存在
existing = await get_system_config(db, tenant_id, config_group, config_key)
if existing is not None:
# 更新
await db.execute(
text("""
UPDATE tenant_configs
SET config_value = :config_value
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
"""),
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key, "config_value": config_value}
)
else:
# 插入
await db.execute(
text("""
INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
VALUES (:tenant_id, :config_group, :config_key, :config_value, 'string', :is_encrypted)
"""),
{
"tenant_id": tenant_id,
"config_group": config_group,
"config_key": config_key,
"config_value": config_value,
"is_encrypted": 1 if config_key == 'DINGTALK_APP_SECRET' else 0
}
)
async def get_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str) -> bool:
"""获取功能开关状态"""
# 先查租户级别
result = await db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""),
{"tenant_id": tenant_id, "feature_code": feature_code}
)
row = result.fetchone()
if row:
return bool(row[0])
# 再查默认值
result = await db.execute(
text("""
SELECT is_enabled FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL
"""),
{"feature_code": feature_code}
)
row = result.fetchone()
return bool(row[0]) if row else False
async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool):
"""设置功能开关状态"""
# 检查是否已存在租户级配置
result = await db.execute(
text("""
SELECT id FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
"""),
{"tenant_id": tenant_id, "feature_code": feature_code}
)
row = result.fetchone()
if row:
# 更新
await db.execute(
text("""
UPDATE feature_switches
SET is_enabled = :is_enabled
WHERE tenant_id = :tenant_id AND feature_code = :feature_code
"""),
{"tenant_id": tenant_id, "feature_code": feature_code, "is_enabled": 1 if is_enabled else 0}
)
else:
# 获取默认配置信息
result = await db.execute(
text("""
SELECT feature_name, feature_group, description FROM feature_switches
WHERE feature_code = :feature_code AND tenant_id IS NULL
"""),
{"feature_code": feature_code}
)
default_row = result.fetchone()
if default_row:
# 插入租户级配置
await db.execute(
text("""
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description)
VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description)
"""),
{
"tenant_id": tenant_id,
"feature_code": feature_code,
"feature_name": default_row[0],
"feature_group": default_row[1],
"is_enabled": 1 if is_enabled else 0,
"description": default_row[2]
}
)
# ============================================
# API 端点
# ============================================
@router.get("/dingtalk", response_model=ResponseModel)
async def get_dingtalk_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取钉钉配置
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 获取配置
app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
agent_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID')
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
# 脱敏处理 app_secret
app_secret_masked = None
if app_secret:
if len(app_secret) > 8:
app_secret_masked = app_secret[:4] + '****' + app_secret[-4:]
else:
app_secret_masked = '****'
return ResponseModel(
message="获取成功",
data={
"app_key": app_key,
"app_secret_masked": app_secret_masked,
"agent_id": agent_id,
"corp_id": corp_id,
"enabled": enabled,
}
)
@router.put("/dingtalk", response_model=ResponseModel)
async def update_dingtalk_config(
config: DingtalkConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
更新钉钉配置
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
try:
# 更新配置
if config.app_key is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key)
if config.app_secret is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret)
if config.agent_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id)
if config.corp_id is not None:
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id)
if config.enabled is not None:
await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled)
await db.commit()
logger.info(
"钉钉配置已更新",
user_id=current_user.id,
username=current_user.username,
)
return ResponseModel(message="配置已保存")
except Exception as e:
await db.rollback()
logger.error(f"更新钉钉配置失败: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="保存配置失败"
)
@router.get("/all", response_model=ResponseModel)
async def get_all_settings(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
) -> ResponseModel:
"""
获取所有系统设置概览
仅限管理员访问
"""
check_admin_permission(current_user)
tenant_id = await get_or_create_tenant_id(db)
# 钉钉配置状态
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
return ResponseModel(
message="获取成功",
data={
"dingtalk": {
"enabled": dingtalk_enabled,
"configured": bool(dingtalk_corp_id), # 是否已配置
}
}
)

View File

@@ -80,6 +80,8 @@ class User(BaseModel, SoftDeleteMixin):
major: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) major: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# 企微员工userid用于SCRM系统对接 # 企微员工userid用于SCRM系统对接
wework_userid: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, comment="企微员工userid") wework_userid: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, comment="企微员工userid")
# 钉钉用户ID用于钉钉免密登录
dingtalk_id: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, comment="钉钉用户ID")
# 系统角色admin, manager, trainee # 系统角色admin, manager, trainee
role: Mapped[str] = mapped_column(String(20), default="trainee", nullable=False) role: Mapped[str] = mapped_column(String(20), default="trainee", nullable=False)

View File

@@ -33,3 +33,10 @@ class RefreshTokenRequest(BaseSchema):
"""刷新令牌请求""" """刷新令牌请求"""
refresh_token: str refresh_token: str
class DingtalkLoginRequest(BaseSchema):
"""钉钉免密登录请求"""
code: str = Field(..., description="钉钉免登授权码")
tenant_id: int = Field(default=1, description="租户ID")

View File

@@ -0,0 +1,294 @@
"""
钉钉认证服务
提供钉钉免密登录功能,从数据库读取配置
"""
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查找系统用户
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
if not user:
# 尝试通过手机号匹配
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
mobile = user_detail.get("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}")
if not user:
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"),
}

View File

@@ -255,6 +255,11 @@ class EmployeeSyncService:
existing_user = result.scalar_one_or_none() existing_user = result.scalar_one_or_none()
if existing_user: if existing_user:
# 如果用户已存在但没有dingtalk_id则更新
dingtalk_id = employee_data.get('dingtalk_id')
if dingtalk_id and not existing_user.dingtalk_id:
existing_user.dingtalk_id = dingtalk_id
logger.info(f"更新用户 {phone} 的钉钉ID: {dingtalk_id}")
logger.info(f"用户已存在: {phone} ({full_name})") logger.info(f"用户已存在: {phone} ({full_name})")
return existing_user return existing_user
@@ -285,7 +290,8 @@ class EmployeeSyncService:
full_name=full_name, full_name=full_name,
role=role, role=role,
is_active=True, is_active=True,
is_verified=True is_verified=True,
dingtalk_id=employee_data.get('dingtalk_id'), # 钉钉用户ID
) )
self.db.add(user) self.db.add(user)

View File

@@ -62,6 +62,16 @@ class UserService(BaseService[User]):
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_dingtalk_id(self, dingtalk_id: str) -> Optional[User]:
"""根据钉钉用户ID获取用户"""
result = await self.db.execute(
select(User).where(
User.dingtalk_id == dingtalk_id,
User.is_deleted == False,
)
)
return result.scalar_one_or_none()
async def _check_username_exists_all(self, username: str) -> Optional[User]: async def _check_username_exists_all(self, username: str) -> Optional[User]:
""" """
检查用户名是否已存在(包括已删除的用户) 检查用户名是否已存在(包括已删除的用户)

View File

@@ -0,0 +1,41 @@
-- =====================================================
-- 钉钉免密登录功能 - 数据库迁移脚本
-- 创建时间: 2026-01-28
-- 说明: 为考培练系统添加钉钉免密登录支持
-- =====================================================
-- 1. 用户表添加 dingtalk_id 字段
-- -----------------------------------------------------
ALTER TABLE users ADD COLUMN dingtalk_id VARCHAR(64) UNIQUE COMMENT '钉钉用户ID';
CREATE INDEX idx_users_dingtalk_id ON users(dingtalk_id);
-- 2. 配置模板表添加钉钉配置项
-- -----------------------------------------------------
INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, is_required, is_secret, sort_order) VALUES
('dingtalk', 'DINGTALK_APP_KEY', 'AppKey', '钉钉应用的AppKey从钉钉开放平台获取', 'string', 1, 0, 1),
('dingtalk', 'DINGTALK_APP_SECRET', 'AppSecret', '钉钉应用的AppSecret敏感信息', 'string', 1, 1, 2),
('dingtalk', 'DINGTALK_AGENT_ID', 'AgentId', '钉钉应用的AgentId', 'string', 1, 0, 3),
('dingtalk', 'DINGTALK_CORP_ID', 'CorpId', '钉钉企业的CorpId', 'string', 1, 0, 4);
-- 3. 功能开关表添加钉钉免密登录开关(默认禁用)
-- -----------------------------------------------------
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES
(NULL, 'dingtalk_login', '钉钉免密登录', 'auth', 0, '启用后,用户可通过钉钉免密登录系统');
-- =====================================================
-- 回滚脚本如需回滚执行以下SQL
-- =====================================================
/*
-- 回滚步骤1: 删除功能开关
DELETE FROM feature_switches WHERE feature_code = 'dingtalk_login';
-- 回滚步骤2: 删除配置模板
DELETE FROM config_templates WHERE config_group = 'dingtalk';
-- 回滚步骤3: 删除用户表字段
ALTER TABLE users DROP INDEX idx_users_dingtalk_id;
ALTER TABLE users DROP COLUMN dingtalk_id;
*/

View File

@@ -102,3 +102,34 @@ export const getCurrentUser = (): Promise<ApiResponse<UserInfo>> => {
export const resetPasswordRequest = (email: string): Promise<ApiResponse<null>> => { export const resetPasswordRequest = (email: string): Promise<ApiResponse<null>> => {
return request.post('/api/v1/auth/reset-password', { email }) return request.post('/api/v1/auth/reset-password', { email })
} }
// ============================================
// 钉钉免密登录 API
// ============================================
// 钉钉登录请求参数
export interface DingtalkLoginParams {
code: string
tenant_id?: number
}
// 钉钉配置响应
export interface DingtalkConfig {
enabled: boolean
corp_id: string | null
agent_id: string | null
}
/**
* 钉钉免密登录
*/
export const dingtalkLogin = (data: DingtalkLoginParams): Promise<ApiResponse<LoginResult>> => {
return request.post('/api/v1/auth/dingtalk/login', data)
}
/**
* 获取钉钉配置用于前端初始化JSDK
*/
export const getDingtalkConfig = (tenantId: number = 1): Promise<ApiResponse<DingtalkConfig>> => {
return request.get('/api/v1/auth/dingtalk/config', { params: { tenant_id: tenantId } })
}

View File

@@ -245,6 +245,12 @@ const routes: RouteRecordRaw[] = [
name: 'AdminLogs', name: 'AdminLogs',
component: () => import('@/views/admin/logs.vue'), component: () => import('@/views/admin/logs.vue'),
meta: { title: '系统日志', icon: 'Files' } meta: { title: '系统日志', icon: 'Files' }
},
{
path: 'settings',
name: 'SystemSettings',
component: () => import('@/views/admin/system-settings.vue'),
meta: { title: '系统设置', icon: 'Setting' }
} }
] ]
}, },

View File

@@ -0,0 +1,190 @@
/**
* 钉钉SDK工具类
*
* 提供钉钉环境检测、免登授权码获取等功能
*/
// 钉钉JSAPI类型声明
declare global {
interface Window {
dd?: {
env: {
platform: 'notInDingTalk' | 'android' | 'ios' | 'pc'
}
ready: (callback: () => void) => void
error: (callback: (err: any) => void) => void
runtime: {
permission: {
requestAuthCode: (options: {
corpId: string
onSuccess: (result: { code: string }) => void
onFail: (err: any) => void
}) => void
}
}
biz: {
navigation: {
setTitle: (options: { title: string }) => void
}
}
}
}
}
/**
* 钉钉配置接口
*/
export interface DingtalkConfig {
enabled: boolean
corp_id: string | null
agent_id: string | null
}
/**
* 检测是否在钉钉环境中
*/
export function isDingtalkEnv(): boolean {
if (typeof window === 'undefined') return false
if (!window.dd) return false
return window.dd.env.platform !== 'notInDingTalk'
}
/**
* 获取钉钉平台类型
*/
export function getDingtalkPlatform(): string {
if (!window.dd) return 'notInDingTalk'
return window.dd.env.platform
}
/**
* 等待钉钉SDK就绪
*/
export function waitDingtalkReady(): Promise<void> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
window.dd.ready(() => {
resolve()
})
window.dd.error((err) => {
reject(err)
})
})
}
/**
* 获取钉钉免登授权码
*
* @param corpId 企业CorpId
* @returns 免登授权码
*/
export function getAuthCode(corpId: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!window.dd) {
reject(new Error('钉钉SDK未加载'))
return
}
if (!isDingtalkEnv()) {
reject(new Error('当前不在钉钉环境中'))
return
}
window.dd.runtime.permission.requestAuthCode({
corpId: corpId,
onSuccess: (result) => {
resolve(result.code)
},
onFail: (err) => {
console.error('获取钉钉授权码失败:', err)
reject(new Error(err.message || '获取授权码失败'))
}
})
})
}
/**
* 设置钉钉页面标题
*/
export function setDingtalkTitle(title: string): void {
if (!window.dd || !isDingtalkEnv()) return
try {
window.dd.biz.navigation.setTitle({ title })
} catch (e) {
console.warn('设置钉钉标题失败:', e)
}
}
/**
* 加载钉钉JSAPI SDK
*
* 动态加载钉钉SDK脚本
*/
export function loadDingtalkSDK(): Promise<void> {
return new Promise((resolve, reject) => {
// 如果已经加载过,直接返回
if (window.dd) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js'
script.async = true
script.onload = () => {
console.log('钉钉SDK加载成功')
resolve()
}
script.onerror = () => {
reject(new Error('钉钉SDK加载失败'))
}
document.head.appendChild(script)
})
}
/**
* 钉钉免密登录完整流程
*
* @param corpId 企业CorpId
* @param loginApi 登录API函数
* @returns 登录结果
*/
export async function dingtalkAutoLogin(
corpId: string,
loginApi: (code: string) => Promise<any>
): Promise<any> {
// 1. 检测钉钉环境
if (!isDingtalkEnv()) {
throw new Error('当前不在钉钉环境中,无法使用免密登录')
}
// 2. 等待SDK就绪
await waitDingtalkReady()
// 3. 获取授权码
const code = await getAuthCode(corpId)
// 4. 调用登录API
const result = await loginApi(code)
return result
}
export default {
isDingtalkEnv,
getDingtalkPlatform,
waitDingtalkReady,
getAuthCode,
setDingtalkTitle,
loadDingtalkSDK,
dingtalkAutoLogin
}

View File

@@ -0,0 +1,251 @@
<template>
<div class="system-settings-container">
<el-card shadow="hover" class="settings-card">
<template #header>
<div class="card-header">
<span>系统设置</span>
</div>
</template>
<el-tabs v-model="activeTab" type="border-card">
<!-- 钉钉配置 -->
<el-tab-pane label="钉钉免密登录" name="dingtalk">
<div class="tab-content">
<el-alert
title="钉钉免密登录配置说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 20px;"
>
<template #default>
<p>配置后员工可以通过钉钉客户端直接登录系统无需输入用户名密码</p>
<p style="margin-top: 8px;">
<a href="https://open-dev.dingtalk.com" target="_blank" class="link">
前往钉钉开放平台获取配置
</a>
</p>
</template>
</el-alert>
<el-form
ref="dingtalkFormRef"
:model="dingtalkForm"
:rules="dingtalkRules"
label-width="140px"
v-loading="loading"
>
<el-form-item label="启用钉钉登录">
<el-switch
v-model="dingtalkForm.enabled"
active-text="已启用"
inactive-text="已禁用"
/>
</el-form-item>
<el-divider content-position="left">钉钉应用配置</el-divider>
<el-form-item label="AppKey" prop="app_key">
<el-input
v-model="dingtalkForm.app_key"
placeholder="请输入钉钉应用的AppKey"
style="width: 400px;"
/>
</el-form-item>
<el-form-item label="AppSecret" prop="app_secret">
<el-input
v-model="dingtalkForm.app_secret"
type="password"
show-password
:placeholder="dingtalkForm.app_secret_masked || '请输入钉钉应用的AppSecret'"
style="width: 400px;"
/>
<span class="form-tip" v-if="dingtalkForm.app_secret_masked && !dingtalkForm.app_secret">
当前值: {{ dingtalkForm.app_secret_masked }}如需修改请重新输入
</span>
</el-form-item>
<el-form-item label="AgentId" prop="agent_id">
<el-input
v-model="dingtalkForm.agent_id"
placeholder="请输入钉钉应用的AgentId"
style="width: 400px;"
/>
</el-form-item>
<el-form-item label="CorpId" prop="corp_id">
<el-input
v-model="dingtalkForm.corp_id"
placeholder="请输入钉钉企业的CorpId"
style="width: 400px;"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveDingtalkConfig" :loading="saving">
保存配置
</el-button>
<el-button @click="loadDingtalkConfig">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- 其他设置预留 -->
<el-tab-pane label="其他设置" name="other" disabled>
<div class="tab-content">
<el-empty description="暂无其他设置项" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { request } from '@/api/request'
const activeTab = ref('dingtalk')
const loading = ref(false)
const saving = ref(false)
const dingtalkFormRef = ref<FormInstance>()
// 钉钉配置表单
const dingtalkForm = reactive({
enabled: false,
app_key: '',
app_secret: '',
app_secret_masked: '', // 用于显示脱敏后的值
agent_id: '',
corp_id: '',
})
// 表单验证规则
const dingtalkRules = reactive<FormRules>({
app_key: [
{ required: false, message: '请输入AppKey', trigger: 'blur' }
],
agent_id: [
{ required: false, message: '请输入AgentId', trigger: 'blur' }
],
corp_id: [
{ required: false, message: '请输入CorpId', trigger: 'blur' }
]
})
/**
* 加载钉钉配置
*/
const loadDingtalkConfig = async () => {
loading.value = true
try {
const response = await request.get('/api/v1/settings/dingtalk')
if (response.code === 200 && response.data) {
dingtalkForm.enabled = response.data.enabled || false
dingtalkForm.app_key = response.data.app_key || ''
dingtalkForm.app_secret = '' // 不回显密钥
dingtalkForm.app_secret_masked = response.data.app_secret_masked || ''
dingtalkForm.agent_id = response.data.agent_id || ''
dingtalkForm.corp_id = response.data.corp_id || ''
}
} catch (error: any) {
console.error('加载钉钉配置失败:', error)
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
/**
* 保存钉钉配置
*/
const saveDingtalkConfig = async () => {
if (!dingtalkFormRef.value) return
await dingtalkFormRef.value.validate(async (valid) => {
if (valid) {
saving.value = true
try {
// 构建更新数据,只发送有值的字段
const updateData: any = {
enabled: dingtalkForm.enabled,
}
if (dingtalkForm.app_key) {
updateData.app_key = dingtalkForm.app_key
}
if (dingtalkForm.app_secret) {
updateData.app_secret = dingtalkForm.app_secret
}
if (dingtalkForm.agent_id) {
updateData.agent_id = dingtalkForm.agent_id
}
if (dingtalkForm.corp_id) {
updateData.corp_id = dingtalkForm.corp_id
}
const response = await request.put('/api/v1/settings/dingtalk', updateData)
if (response.code === 200) {
ElMessage.success('配置保存成功')
// 重新加载配置
await loadDingtalkConfig()
} else {
ElMessage.error(response.message || '保存失败')
}
} catch (error: any) {
console.error('保存钉钉配置失败:', error)
ElMessage.error('保存配置失败')
} finally {
saving.value = false
}
}
})
}
// 页面加载时获取配置
onMounted(() => {
loadDingtalkConfig()
})
</script>
<style lang="scss" scoped>
.system-settings-container {
padding: 20px;
.settings-card {
.card-header {
font-size: 18px;
font-weight: 600;
}
}
.tab-content {
padding: 20px;
min-height: 400px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-left: 12px;
}
.link {
color: #409eff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(.el-divider__text) {
font-size: 14px;
color: #606266;
}
}
</style>

View File

@@ -67,21 +67,35 @@
</el-button> </el-button>
</el-form-item> </el-form-item>
<div class="other-login"> <div class="other-login" v-if="dingtalkConfig.enabled || !isDingtalk">
<el-divider>其他登录方式</el-divider> <el-divider>其他登录方式</el-divider>
<div class="social-icons"> <div class="social-icons">
<!-- 钉钉登录按钮仅在启用且非钉钉环境时显示 -->
<div
v-if="dingtalkConfig.enabled && !isDingtalk"
class="social-icon dingtalk-icon"
@click="handleDingtalkLogin"
title="钉钉登录"
>
<svg viewBox="0 0 1024 1024" width="22" height="22">
<path d="M512 0C229.2 0 0 229.2 0 512s229.2 512 512 512 512-229.2 512-512S794.8 0 512 0z m259.3 568.7l-197.8 3.3-59.4 143.1c-3.6 8.6-15.7 7.5-17.9-1.6l-45.2-188.5-241.9-69c-10.8-3.1-10.6-18.4 0.3-21.2l492.3-126c11.3-2.9 21.4 7.6 18 18.7l-77.4 252.3c-2.7 8.8-15.1 10.1-19.7 2.1l-51.3-90.8-90.8 51.3c-8 4.5-17.6-2-15.9-10.8l34.8-188.4-213.7 54.7 213.7 61.1 19.2 80.1 32.6-78.8 240.1-4z" fill="#3296FA"/>
</svg>
</div>
<div class="social-icon" @click="socialLogin('wechat')"> <div class="social-icon" @click="socialLogin('wechat')">
<el-icon :size="20"><ChatDotRound /></el-icon> <el-icon :size="20"><ChatDotRound /></el-icon>
</div> </div>
<div class="social-icon" @click="socialLogin('qq')"> <div class="social-icon" @click="socialLogin('qq')">
<el-icon :size="20"><Connection /></el-icon> <el-icon :size="20"><Connection /></el-icon>
</div> </div>
<div class="social-icon" @click="socialLogin('github')">
<el-icon :size="20"><Link /></el-icon>
</div>
</div> </div>
</div> </div>
<!-- 钉钉环境中的自动登录提示 -->
<div v-if="isDingtalk && dingtalkLoading" class="dingtalk-loading">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<span>正在通过钉钉自动登录...</span>
</div>
<div class="register-link"> <div class="register-link">
还没有账号 还没有账号
<el-link type="primary" :underline="false" @click="goRegister"> <el-link type="primary" :underline="false" @click="goRegister">
@@ -94,17 +108,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { login } from '@/api/auth' import { login, dingtalkLogin, getDingtalkConfig } from '@/api/auth'
import type { DingtalkConfig } from '@/api/auth'
import { authManager } from '@/utils/auth' import { authManager } from '@/utils/auth'
import { isDingtalkEnv, loadDingtalkSDK, getAuthCode, waitDingtalkReady } from '@/utils/dingtalk'
const router = useRouter() const router = useRouter()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const loading = ref(false) const loading = ref(false)
// 钉钉相关状态
const isDingtalk = ref(false)
const dingtalkLoading = ref(false)
const dingtalkConfig = reactive<DingtalkConfig>({
enabled: false,
corp_id: null,
agent_id: null
})
// 登录表单 // 登录表单
const loginForm = reactive({ const loginForm = reactive({
username: '', username: '',
@@ -192,6 +218,113 @@ const socialLogin = (type: string) => {
const goRegister = () => { const goRegister = () => {
ElMessage.info('注册功能开发中') ElMessage.info('注册功能开发中')
} }
/**
* 钉钉登录成功处理
*/
const handleDingtalkLoginSuccess = (response: any) => {
// 保存认证信息
authManager.setAccessToken(response.data.token.access_token)
authManager.setRefreshToken(response.data.token.refresh_token)
const userInfo = {
...response.data.user,
created_at: response.data.user.created_at || new Date().toISOString(),
updated_at: response.data.user.updated_at || new Date().toISOString()
}
authManager.setCurrentUser(userInfo)
ElMessage.success('钉钉登录成功')
// 跳转
const redirect = new URLSearchParams(window.location.search).get('redirect') || authManager.getDefaultRoute()
router.push(redirect)
}
/**
* 钉钉免密登录(在钉钉环境中自动触发)
*/
const autoDingtalkLogin = async () => {
if (!dingtalkConfig.corp_id) {
console.warn('钉钉CorpId未配置')
return
}
dingtalkLoading.value = true
try {
// 等待钉钉SDK就绪
await waitDingtalkReady()
// 获取免登授权码
const code = await getAuthCode(dingtalkConfig.corp_id)
// 调用登录API
const response = await dingtalkLogin({ code })
if (response.code === 200) {
handleDingtalkLoginSuccess(response)
} else {
ElMessage.error(response.message || '钉钉登录失败')
}
} catch (error: any) {
console.error('钉钉自动登录失败:', error)
ElMessage.warning('钉钉自动登录失败,请使用账号密码登录')
} finally {
dingtalkLoading.value = false
}
}
/**
* 手动触发钉钉登录(非钉钉环境下点击钉钉登录按钮)
*/
const handleDingtalkLogin = () => {
if (isDingtalk.value) {
// 在钉钉环境中,重新触发自动登录
autoDingtalkLogin()
} else {
// 非钉钉环境,提示用户
ElMessage.info('请在钉钉客户端中打开本应用以使用免密登录')
}
}
/**
* 初始化钉钉登录
*/
const initDingtalkLogin = async () => {
try {
// 获取钉钉配置
const response = await getDingtalkConfig()
if (response.code === 200 && response.data) {
dingtalkConfig.enabled = response.data.enabled
dingtalkConfig.corp_id = response.data.corp_id
dingtalkConfig.agent_id = response.data.agent_id
}
// 检测钉钉环境
isDingtalk.value = isDingtalkEnv()
// 如果在钉钉环境中且钉钉登录已启用,自动触发登录
if (isDingtalk.value && dingtalkConfig.enabled && dingtalkConfig.corp_id) {
autoDingtalkLogin()
}
} catch (error) {
console.error('初始化钉钉登录失败:', error)
}
}
// 页面加载时初始化
onMounted(async () => {
// 尝试加载钉钉SDK
try {
await loadDingtalkSDK()
} catch (e) {
console.log('钉钉SDK加载跳过非必须')
}
// 初始化钉钉登录
initDingtalkLogin()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -326,10 +459,36 @@ const goRegister = () => {
color: #667eea; color: #667eea;
transform: translateY(-2px); transform: translateY(-2px);
} }
&.dingtalk-icon {
&:hover {
border-color: #3296FA;
background-color: rgba(50, 150, 250, 0.1);
}
}
} }
} }
} }
.dingtalk-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
margin-top: 20px;
color: #666;
.el-icon {
margin-bottom: 12px;
color: #3296FA;
}
span {
font-size: 14px;
}
}
.register-link { .register-link {
text-align: center; text-align: center;
margin-top: 24px; margin-top: 24px;