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), # 是否已配置
}
}
)

View File

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

View File

@@ -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")

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()
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)

View File

@@ -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]:
"""
检查用户名是否已存在(包括已删除的用户)

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;
*/