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"])
# admin_portal_router SaaS超级管理后台路由prefix在router内部定义为/admin
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"]

View File

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

View File

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

View File

@@ -1,16 +1,17 @@
"""
认证 API
"""
from fastapi import APIRouter, Depends, status, Request
from fastapi import APIRouter, Depends, status, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession
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.auth import LoginRequest, RefreshTokenRequest, Token
from app.schemas.auth import LoginRequest, RefreshTokenRequest, Token, DingtalkLoginRequest
from app.schemas.base import ResponseModel
from app.schemas.user import User as UserSchema
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.schemas.system_log import SystemLogCreate
from app.core.exceptions import UnauthorizedError
@@ -154,3 +155,102 @@ async def verify_token(
"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), # 是否已配置
}
}
)