From 662947cd068fd6560cfd208d550dcfa20289c082 Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Thu, 29 Jan 2026 14:40:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=92=89=E9=92=89?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:钉钉 OAuth 认证服务 - 后端:系统设置 API(钉钉配置) - 前端:登录页钉钉扫码入口 - 前端:系统设置页面 - 数据库迁移脚本 --- backend/app/api/v1/__init__.py | 3 + backend/app/api/v1/admin_portal/configs.py | 1 + backend/app/api/v1/admin_portal/features.py | 1 + backend/app/api/v1/auth.py | 104 +++++- backend/app/api/v1/system_settings.py | 306 ++++++++++++++++++ backend/app/models/user.py | 2 + backend/app/schemas/auth.py | 7 + backend/app/services/dingtalk_auth_service.py | 294 +++++++++++++++++ backend/app/services/employee_sync_service.py | 8 +- backend/app/services/user_service.py | 10 + backend/migrations/add_dingtalk_login.sql | 41 +++ frontend/src/api/auth/index.ts | 31 ++ frontend/src/router/index.ts | 6 + frontend/src/utils/dingtalk.ts | 190 +++++++++++ frontend/src/views/admin/system-settings.vue | 251 ++++++++++++++ frontend/src/views/login/index.vue | 171 +++++++++- 16 files changed, 1417 insertions(+), 9 deletions(-) create mode 100644 backend/app/api/v1/system_settings.py create mode 100644 backend/app/services/dingtalk_auth_service.py create mode 100644 backend/migrations/add_dingtalk_login.sql create mode 100644 frontend/src/utils/dingtalk.ts create mode 100644 frontend/src/views/admin/system-settings.vue diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index fbb89d1..62aa263 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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"] diff --git a/backend/app/api/v1/admin_portal/configs.py b/backend/app/api/v1/admin_portal/configs.py index 98811b2..83a9e23 100644 --- a/backend/app/api/v1/admin_portal/configs.py +++ b/backend/app/api/v1/admin_portal/configs.py @@ -32,6 +32,7 @@ CONFIG_GROUP_NAMES = { "yanji": "言迹工牌配置", "storage": "文件存储配置", "basic": "基础配置", + "dingtalk": "钉钉配置", } diff --git a/backend/app/api/v1/admin_portal/features.py b/backend/app/api/v1/admin_portal/features.py index 370cebc..174f0f0 100644 --- a/backend/app/api/v1/admin_portal/features.py +++ b/backend/app/api/v1/admin_portal/features.py @@ -27,6 +27,7 @@ FEATURE_GROUP_NAMES = { "broadcast": "播课模块", "course": "课程模块", "yanji": "智能工牌模块", + "auth": "认证模块", } diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 411e4a2..8d22c9d 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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}, + ) diff --git a/backend/app/api/v1/system_settings.py b/backend/app/api/v1/system_settings.py new file mode 100644 index 0000000..01dd460 --- /dev/null +++ b/backend/app/api/v1/system_settings.py @@ -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), # 是否已配置 + } + } + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 66f8293..d09a5d0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -80,6 +80,8 @@ class User(BaseModel, SoftDeleteMixin): major: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # 企微员工userid(用于SCRM系统对接) 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 role: Mapped[str] = mapped_column(String(20), default="trainee", nullable=False) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 94a4809..943968d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -33,3 +33,10 @@ class RefreshTokenRequest(BaseSchema): """刷新令牌请求""" refresh_token: str + + +class DingtalkLoginRequest(BaseSchema): + """钉钉免密登录请求""" + + code: str = Field(..., description="钉钉免登授权码") + tenant_id: int = Field(default=1, description="租户ID") diff --git a/backend/app/services/dingtalk_auth_service.py b/backend/app/services/dingtalk_auth_service.py new file mode 100644 index 0000000..8aeac19 --- /dev/null +++ b/backend/app/services/dingtalk_auth_service.py @@ -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"), + } diff --git a/backend/app/services/employee_sync_service.py b/backend/app/services/employee_sync_service.py index 5e93c73..4e3585c 100644 --- a/backend/app/services/employee_sync_service.py +++ b/backend/app/services/employee_sync_service.py @@ -255,6 +255,11 @@ class EmployeeSyncService: existing_user = result.scalar_one_or_none() 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})") return existing_user @@ -285,7 +290,8 @@ class EmployeeSyncService: full_name=full_name, role=role, is_active=True, - is_verified=True + is_verified=True, + dingtalk_id=employee_data.get('dingtalk_id'), # 钉钉用户ID ) self.db.add(user) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 3adc275..a957696 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -62,6 +62,16 @@ class UserService(BaseService[User]): ) 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]: """ 检查用户名是否已存在(包括已删除的用户) diff --git a/backend/migrations/add_dingtalk_login.sql b/backend/migrations/add_dingtalk_login.sql new file mode 100644 index 0000000..820dc75 --- /dev/null +++ b/backend/migrations/add_dingtalk_login.sql @@ -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; +*/ diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index 3cd4b4b..a3583e3 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -102,3 +102,34 @@ export const getCurrentUser = (): Promise> => { export const resetPasswordRequest = (email: string): Promise> => { 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> => { + return request.post('/api/v1/auth/dingtalk/login', data) +} + +/** + * 获取钉钉配置(用于前端初始化JSDK) + */ +export const getDingtalkConfig = (tenantId: number = 1): Promise> => { + return request.get('/api/v1/auth/dingtalk/config', { params: { tenant_id: tenantId } }) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index fbfe3c6..aae73af 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -245,6 +245,12 @@ const routes: RouteRecordRaw[] = [ name: 'AdminLogs', component: () => import('@/views/admin/logs.vue'), meta: { title: '系统日志', icon: 'Files' } + }, + { + path: 'settings', + name: 'SystemSettings', + component: () => import('@/views/admin/system-settings.vue'), + meta: { title: '系统设置', icon: 'Setting' } } ] }, diff --git a/frontend/src/utils/dingtalk.ts b/frontend/src/utils/dingtalk.ts new file mode 100644 index 0000000..a0bffc3 --- /dev/null +++ b/frontend/src/utils/dingtalk.ts @@ -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 { + 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 { + 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 { + 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 +): Promise { + // 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 +} diff --git a/frontend/src/views/admin/system-settings.vue b/frontend/src/views/admin/system-settings.vue new file mode 100644 index 0000000..b098f9c --- /dev/null +++ b/frontend/src/views/admin/system-settings.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index c6b1d4d..a2e8848 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -67,20 +67,34 @@ -