From 78e1bb3dc37dd1938762744a917a1682ec834e1f Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Sat, 31 Jan 2026 17:01:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=91=98=E5=B7=A5=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81=E5=A4=9A=E7=A7=9F=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增员工同步配置API(获取/保存/测试连接) - employee_sync_service 从数据库读取配置 - 前端系统设置页面添加"员工同步"Tab - 支持配置:数据库主机、端口、库名、用户名、密码、表名 - 保留默认配置用于向后兼容 --- backend/app/api/v1/system_settings.py | 178 ++++++++++++++- backend/app/services/employee_sync_service.py | 56 ++++- frontend/src/views/admin/system-settings.vue | 212 +++++++++++++++++- 3 files changed, 435 insertions(+), 11 deletions(-) diff --git a/backend/app/api/v1/system_settings.py b/backend/app/api/v1/system_settings.py index e6ff96b..01fae31 100644 --- a/backend/app/api/v1/system_settings.py +++ b/backend/app/api/v1/system_settings.py @@ -40,6 +40,17 @@ class DingtalkConfigResponse(BaseModel): enabled: bool = False +class EmployeeSyncConfigUpdate(BaseModel): + """员工同步配置更新请求""" + db_host: Optional[str] = Field(None, description="数据库主机") + db_port: Optional[int] = Field(None, description="数据库端口") + db_name: Optional[str] = Field(None, description="数据库名") + db_user: Optional[str] = Field(None, description="数据库用户名") + db_password: Optional[str] = Field(None, description="数据库密码") + table_name: Optional[str] = Field(None, description="员工表/视图名称") + enabled: Optional[bool] = Field(None, description="是否启用自动同步") + + # ============================================ # 辅助函数 # ============================================ @@ -277,6 +288,163 @@ async def update_dingtalk_config( ) +@router.get("/employee-sync", response_model=ResponseModel) +async def get_employee_sync_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) + + # 获取配置 + db_host = await get_system_config(db, tenant_id, 'employee_sync', 'DB_HOST') + db_port = await get_system_config(db, tenant_id, 'employee_sync', 'DB_PORT') + db_name = await get_system_config(db, tenant_id, 'employee_sync', 'DB_NAME') + db_user = await get_system_config(db, tenant_id, 'employee_sync', 'DB_USER') + db_password = await get_system_config(db, tenant_id, 'employee_sync', 'DB_PASSWORD') + table_name = await get_system_config(db, tenant_id, 'employee_sync', 'TABLE_NAME') + enabled = await get_feature_switch(db, tenant_id, 'employee_sync') + + # 脱敏处理密码 + password_masked = None + if db_password: + if len(db_password) > 4: + password_masked = '****' + db_password[-2:] + else: + password_masked = '****' + + return ResponseModel( + message="获取成功", + data={ + "db_host": db_host, + "db_port": int(db_port) if db_port else None, + "db_name": db_name, + "db_user": db_user, + "db_password_masked": password_masked, + "table_name": table_name or "v_钉钉员工表", + "enabled": enabled, + } + ) + + +@router.put("/employee-sync", response_model=ResponseModel) +async def update_employee_sync_config( + config: EmployeeSyncConfigUpdate, + 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.db_host is not None: + await set_system_config(db, tenant_id, 'employee_sync', 'DB_HOST', config.db_host) + + if config.db_port is not None: + await set_system_config(db, tenant_id, 'employee_sync', 'DB_PORT', str(config.db_port)) + + if config.db_name is not None: + await set_system_config(db, tenant_id, 'employee_sync', 'DB_NAME', config.db_name) + + if config.db_user is not None: + await set_system_config(db, tenant_id, 'employee_sync', 'DB_USER', config.db_user) + + if config.db_password is not None: + await set_system_config(db, tenant_id, 'employee_sync', 'DB_PASSWORD', config.db_password) + + if config.table_name is not None: + await set_system_config(db, tenant_id, 'employee_sync', 'TABLE_NAME', config.table_name) + + if config.enabled is not None: + await set_feature_switch(db, tenant_id, 'employee_sync', 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.post("/employee-sync/test", response_model=ResponseModel) +async def test_employee_sync_connection( + 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) + + # 获取配置 + db_host = await get_system_config(db, tenant_id, 'employee_sync', 'DB_HOST') + db_port = await get_system_config(db, tenant_id, 'employee_sync', 'DB_PORT') + db_name = await get_system_config(db, tenant_id, 'employee_sync', 'DB_NAME') + db_user = await get_system_config(db, tenant_id, 'employee_sync', 'DB_USER') + db_password = await get_system_config(db, tenant_id, 'employee_sync', 'DB_PASSWORD') + table_name = await get_system_config(db, tenant_id, 'employee_sync', 'TABLE_NAME') or "v_钉钉员工表" + + if not all([db_host, db_port, db_name, db_user, db_password]): + return ResponseModel( + code=400, + message="配置不完整,请先填写所有数据库连接信息" + ) + + try: + from sqlalchemy.ext.asyncio import create_async_engine + + # 构建连接URL + db_url = f"mysql+aiomysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}?charset=utf8mb4" + + engine = create_async_engine(db_url, echo=False, pool_pre_ping=True) + + async with engine.connect() as conn: + # 测试查询员工表 + result = await conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")) + count = result.scalar() + + await engine.dispose() + + return ResponseModel( + message=f"连接成功!员工表共有 {count} 条记录", + data={"employee_count": count} + ) + + except Exception as e: + logger.error(f"测试连接失败: {str(e)}") + return ResponseModel( + code=500, + message=f"连接失败: {str(e)}" + ) + + @router.get("/all", response_model=ResponseModel) async def get_all_settings( current_user: User = Depends(get_current_active_user), @@ -295,12 +463,20 @@ async def get_all_settings( 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') + # 员工同步配置状态 + employee_sync_enabled = await get_feature_switch(db, tenant_id, 'employee_sync') + employee_sync_host = await get_system_config(db, tenant_id, 'employee_sync', 'DB_HOST') + return ResponseModel( message="获取成功", data={ "dingtalk": { "enabled": dingtalk_enabled, - "configured": bool(dingtalk_corp_id), # 是否已配置 + "configured": bool(dingtalk_corp_id), + }, + "employee_sync": { + "enabled": employee_sync_enabled, + "configured": bool(employee_sync_host), } } ) diff --git a/backend/app/services/employee_sync_service.py b/backend/app/services/employee_sync_service.py index 743d6c2..6864ed9 100644 --- a/backend/app/services/employee_sync_service.py +++ b/backend/app/services/employee_sync_service.py @@ -23,17 +23,59 @@ logger = get_logger(__name__) class EmployeeSyncService: """员工同步服务""" - # 外部数据库连接配置 - EXTERNAL_DB_URL = "mysql+aiomysql://neuron_new:NWxGM6CQoMLKyEszXhfuLBIIo1QbeK@120.77.144.233:29613/neuron_new?charset=utf8mb4" + # 默认外部数据库连接配置(向后兼容) + DEFAULT_DB_URL = "mysql+aiomysql://neuron_new:NWxGM6CQoMLKyEszXhfuLBIIo1QbeK@120.77.144.233:29613/neuron_new?charset=utf8mb4" + DEFAULT_TABLE_NAME = "v_钉钉员工表" - def __init__(self, db: AsyncSession): + def __init__(self, db: AsyncSession, tenant_id: int = 1): self.db = db + self.tenant_id = tenant_id self.external_engine = None + self.table_name = self.DEFAULT_TABLE_NAME + self._db_url = None + + async def _get_config_from_db(self) -> tuple: + """从数据库获取员工同步配置""" + result = await self.db.execute( + text(""" + SELECT config_key, config_value + FROM tenant_configs + WHERE tenant_id = :tenant_id AND config_group = 'employee_sync' + """), + {"tenant_id": self.tenant_id} + ) + rows = result.fetchall() + + config = {} + for key, value in rows: + config[key] = value + + return config + + async def _build_db_url(self) -> str: + """构建数据库连接URL""" + config = await self._get_config_from_db() + + db_host = config.get('DB_HOST') + db_port = config.get('DB_PORT') + db_name = config.get('DB_NAME') + db_user = config.get('DB_USER') + db_password = config.get('DB_PASSWORD') + self.table_name = config.get('TABLE_NAME') or self.DEFAULT_TABLE_NAME + + # 如果配置完整,使用配置的URL + if all([db_host, db_port, db_name, db_user, db_password]): + return f"mysql+aiomysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}?charset=utf8mb4" + + # 否则使用默认配置 + logger.warning(f"租户 {self.tenant_id} 未配置员工同步数据库,使用默认配置") + return self.DEFAULT_DB_URL async def __aenter__(self): """异步上下文管理器入口""" + self._db_url = await self._build_db_url() self.external_engine = create_async_engine( - self.EXTERNAL_DB_URL, + self._db_url, echo=False, pool_pre_ping=True, pool_recycle=3600 @@ -52,9 +94,9 @@ class EmployeeSyncService: Returns: 员工数据列表 """ - logger.info("开始从钉钉员工表获取数据...") + logger.info(f"开始从员工表 {self.table_name} 获取数据...") - query = """ + query = f""" SELECT 员工姓名, 手机号, @@ -67,7 +109,7 @@ class EmployeeSyncService: 钉钉用户ID, 入职日期, 工作地点 - FROM v_钉钉员工表 + FROM {self.table_name} WHERE 是否在职 = 1 ORDER BY 员工姓名 """ diff --git a/frontend/src/views/admin/system-settings.vue b/frontend/src/views/admin/system-settings.vue index 6be669e..6e7543b 100644 --- a/frontend/src/views/admin/system-settings.vue +++ b/frontend/src/views/admin/system-settings.vue @@ -92,10 +92,106 @@ - - + +
- + + + + + + + + 启用后将每日自动同步员工数据 + + + 数据库连接配置 + + + + + + + + + + + + + + + + + + + + + 当前值: {{ syncForm.db_password_masked }} + + + + + + 表或视图需包含:员工姓名、手机号、所属部门、职位等字段 + + + + + 保存配置 + + + 测试连接 + + 重置 + +
@@ -114,6 +210,12 @@ const loading = ref(false) const saving = ref(false) const dingtalkFormRef = ref() +// 员工同步配置 +const syncLoading = ref(false) +const syncSaving = ref(false) +const syncTesting = ref(false) +const syncFormRef = ref() + // 钉钉配置表单 const dingtalkForm = reactive({ enabled: false, @@ -124,6 +226,18 @@ const dingtalkForm = reactive({ corp_id: '', }) +// 员工同步配置表单 +const syncForm = reactive({ + enabled: false, + db_host: '', + db_port: 3306, + db_name: '', + db_user: '', + db_password: '', + db_password_masked: '', + table_name: 'v_钉钉员工表', +}) + // 表单验证规则 const dingtalkRules = reactive({ app_key: [ @@ -137,6 +251,18 @@ const dingtalkRules = reactive({ ] }) +const syncRules = reactive({ + db_host: [ + { required: false, message: '请输入数据库主机', trigger: 'blur' } + ], + db_name: [ + { required: false, message: '请输入数据库名', trigger: 'blur' } + ], + db_user: [ + { required: false, message: '请输入用户名', trigger: 'blur' } + ] +}) + /** * 加载钉钉配置 */ @@ -206,9 +332,89 @@ const saveDingtalkConfig = async () => { }) } +/** + * 加载员工同步配置 + */ +const loadSyncConfig = async () => { + syncLoading.value = true + try { + const response = await request.get('/api/v1/settings/employee-sync') + if (response.code === 200 && response.data) { + syncForm.enabled = response.data.enabled || false + syncForm.db_host = response.data.db_host || '' + syncForm.db_port = response.data.db_port || 3306 + syncForm.db_name = response.data.db_name || '' + syncForm.db_user = response.data.db_user || '' + syncForm.db_password = '' + syncForm.db_password_masked = response.data.db_password_masked || '' + syncForm.table_name = response.data.table_name || 'v_钉钉员工表' + } + } catch (error: any) { + console.error('加载员工同步配置失败:', error) + // 不显示错误提示,可能是表不存在 + } finally { + syncLoading.value = false + } +} + +/** + * 保存员工同步配置 + */ +const saveSyncConfig = async () => { + syncSaving.value = true + try { + const updateData: any = { + enabled: syncForm.enabled, + db_host: syncForm.db_host, + db_port: syncForm.db_port, + db_name: syncForm.db_name, + db_user: syncForm.db_user, + table_name: syncForm.table_name, + } + + if (syncForm.db_password) { + updateData.db_password = syncForm.db_password + } + + const response = await request.put('/api/v1/settings/employee-sync', updateData) + if (response.code === 200) { + ElMessage.success('配置保存成功') + await loadSyncConfig() + } else { + ElMessage.error(response.message || '保存失败') + } + } catch (error: any) { + console.error('保存员工同步配置失败:', error) + ElMessage.error('保存配置失败') + } finally { + syncSaving.value = false + } +} + +/** + * 测试员工同步数据库连接 + */ +const testSyncConnection = async () => { + syncTesting.value = true + try { + const response = await request.post('/api/v1/settings/employee-sync/test') + if (response.code === 200) { + ElMessage.success(response.message || '连接成功') + } else { + ElMessage.error(response.message || '连接失败') + } + } catch (error: any) { + console.error('测试连接失败:', error) + ElMessage.error('测试连接失败') + } finally { + syncTesting.value = false + } +} + // 页面加载时获取配置 onMounted(() => { loadDingtalkConfig() + loadSyncConfig() })