""" 員工生命週期管理服務 自動化處理員工的新進、異動、離職流程 """ from typing import Dict, Any, Optional from sqlalchemy.orm import Session import logging import secrets import string from app.models.employee import Employee from app.services.keycloak_admin_client import get_keycloak_admin_client from app.services.drive_service import get_drive_service_client, get_drive_quota_by_job_level from app.services.mailserver_service import get_mailserver_service, get_mail_quota_by_job_level logger = logging.getLogger(__name__) class EmployeeLifecycleService: """員工生命週期管理服務""" def __init__(self): self.keycloak_client = None def _get_keycloak_client(self): """延遲初始化 Keycloak Admin 客戶端""" if self.keycloak_client is None: self.keycloak_client = get_keycloak_admin_client() return self.keycloak_client def _generate_temporary_password(self, length: int = 12) -> str: """生成臨時密碼 (包含大小寫字母、數字和特殊字元)""" alphabet = string.ascii_letters + string.digits + "!@#$%^&*" password = ''.join(secrets.choice(alphabet) for _ in range(length)) return password async def onboard_employee( self, db: Session, employee: Employee, create_keycloak: bool = True, create_email: bool = True, create_drive: bool = True, ) -> Dict[str, Any]: """ 員工到職流程 (Onboarding) 自動執行: 1. 建立 Keycloak SSO 帳號 2. 建立主要郵件帳號 3. 建立雲端硬碟帳號 (Drive Service) 4. 記錄審計日誌 Args: db: 資料庫 Session employee: 員工物件 create_keycloak: 是否建立 Keycloak 帳號 create_email: 是否建立郵件帳號 create_drive: 是否建立雲端硬碟帳號 (非致命,Drive Service 未上線時跳過) Returns: 執行結果字典 """ results = { "employee_id": employee.id, "employee_number": employee.employee_id, "legal_name": employee.legal_name, "username_base": employee.username_base, "keycloak": {"created": False, "error": None}, "email": {"created": False, "error": None}, "drive": {"created": False, "error": None}, } logger.info(f"開始員工到職流程: {employee.employee_id} - {employee.legal_name}") # 1. 建立 Keycloak 帳號 if create_keycloak: try: keycloak_result = await self._create_keycloak_account(employee) results["keycloak"] = keycloak_result logger.info(f"Keycloak 帳號建立: {keycloak_result}") except Exception as e: logger.error(f"建立 Keycloak 帳號失敗: {str(e)}") results["keycloak"]["error"] = str(e) # 2. 建立郵件帳號 if create_email: try: email_result = await self._create_email_account(employee) results["email"] = email_result logger.info(f"郵件帳號建立: {email_result}") except Exception as e: logger.error(f"建立郵件帳號失敗: {str(e)}") results["email"]["error"] = str(e) # 3. 建立雲端硬碟帳號 (Drive Service - 非致命) if create_drive: drive_result = await self._create_drive_account(employee) results["drive"] = drive_result if drive_result.get("error"): logger.warning(f"雲端硬碟帳號建立 (非致命): {drive_result}") else: logger.info(f"雲端硬碟帳號建立: {drive_result}") logger.info(f"員工到職流程完成: {employee.employee_id}") return results async def offboard_employee( self, db: Session, employee: Employee, disable_keycloak: bool = True, handle_email: str = "forward", # "forward" or "disable" disable_drive: bool = True, ) -> Dict[str, Any]: """ 員工離職流程 (Offboarding) 自動執行: 1. 停用 Keycloak SSO 帳號 2. 處理郵件帳號 (轉發或停用) 3. 停用雲端硬碟帳號 (Drive Service - 非致命) 4. 記錄審計日誌 Args: db: 資料庫 Session employee: 員工物件 disable_keycloak: 是否停用 Keycloak 帳號 handle_email: 郵件處理方式 ("forward" 或 "disable") disable_drive: 是否停用雲端硬碟帳號 (非致命,Drive Service 未上線時跳過) Returns: 執行結果字典 """ results = { "employee_id": employee.id, "employee_number": employee.employee_id, "legal_name": employee.legal_name, "keycloak": {"disabled": False, "error": None}, "email": {"handled": False, "method": handle_email, "error": None}, "drive": {"disabled": False, "error": None}, } logger.info(f"開始員工離職流程: {employee.employee_id} - {employee.legal_name}") # 1. 停用 Keycloak 帳號 if disable_keycloak: try: keycloak_result = await self._disable_keycloak_account(employee) results["keycloak"] = keycloak_result logger.info(f"Keycloak 帳號停用: {keycloak_result}") except Exception as e: logger.error(f"停用 Keycloak 帳號失敗: {str(e)}") results["keycloak"]["error"] = str(e) # 2. 處理郵件帳號 try: email_result = await self._handle_email_offboarding(employee, handle_email) results["email"] = email_result logger.info(f"郵件帳號處理: {email_result}") except Exception as e: logger.error(f"處理郵件帳號失敗: {str(e)}") results["email"]["error"] = str(e) # 3. 停用雲端硬碟帳號 (Drive Service - 非致命) if disable_drive: drive_result = await self._disable_drive_account(employee) results["drive"] = drive_result if drive_result.get("error"): logger.warning(f"雲端硬碟帳號停用 (非致命): {drive_result}") else: logger.info(f"雲端硬碟帳號停用: {drive_result}") logger.info(f"員工離職流程完成: {employee.employee_id}") return results async def _create_keycloak_account(self, employee: Employee) -> Dict[str, Any]: """ 建立 Keycloak SSO 帳號 執行步驟: 1. 檢查帳號是否已存在 2. 生成臨時密碼 3. 建立 Keycloak 用戶 4. 設定用戶屬性 (姓名、郵件等) Args: employee: 員工物件 Returns: 執行結果字典 """ try: client = self._get_keycloak_client() username = employee.username_base email = f"{username}@porscheworld.tw" # 1. 檢查帳號是否已存在 existing_user = client.get_user_by_username(username) if existing_user: return { "created": False, "username": username, "email": email, "user_id": existing_user.get("id"), "message": "Keycloak 帳號已存在", "error": "用戶已存在", } # 2. 生成臨時密碼 (12位隨機密碼) temporary_password = self._generate_temporary_password(12) # 3. 分割姓名 (如果有英文名稱使用英文,否則使用中文) if employee.english_name: # 英文名稱格式: "FirstName LastName" 或 "FirstName MiddleName LastName" name_parts = employee.english_name.strip().split() first_name = name_parts[0] if len(name_parts) > 0 else username last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else "" else: # 中文名稱格式: "姓名" (第一個字是姓,其餘是名) legal_name = employee.legal_name or username first_name = legal_name[1:] if len(legal_name) > 1 else legal_name last_name = legal_name[0] if len(legal_name) > 0 else "" # 4. 建立 Keycloak 用戶 user_id = client.create_user( username=username, email=email, first_name=first_name, last_name=last_name, enabled=True, email_verified=True, ) if not user_id: return { "created": False, "username": username, "email": email, "message": "Keycloak 用戶建立失敗", "error": "無法建立用戶 (API 返回 None)", } # 5. 設定初始密碼 password_set = client.reset_password( user_id=user_id, password=temporary_password, temporary=True # 用戶首次登入需修改密碼 ) if not password_set: logger.warning(f"Keycloak 用戶 {username} 建立成功,但密碼設定失敗") logger.info(f"✓ Keycloak 帳號建立成功: {username} (ID: {user_id})") return { "created": True, "username": username, "email": email, "user_id": user_id, "first_name": first_name, "last_name": last_name, "temporary_password": temporary_password, # 應透過安全方式通知用戶 "password_set": password_set, "message": f"Keycloak 帳號建立成功 (用戶首次登入需修改密碼)", "error": None, } except Exception as e: logger.error(f"✗ 建立 Keycloak 帳號時發生錯誤: {str(e)}") return { "created": False, "username": employee.username_base, "email": f"{employee.username_base}@porscheworld.tw", "message": "建立 Keycloak 帳號時發生錯誤", "error": str(e), } async def _create_email_account(self, employee: Employee) -> Dict[str, Any]: """ 建立郵件帳號 (Docker Mailserver) 執行步驟: 1. 依職級取得配額 2. 產生臨時密碼 (員工後續透過 Keycloak SSO 登入) 3. 透過 SSH + docker exec 建立帳號 4. 設定配額 """ email_address = f"{employee.username_base}@porscheworld.tw" try: mailserver = get_mailserver_service() # 依職級取得郵件配額 job_level = getattr(employee, "job_level", "Junior") or "Junior" quota_mb = get_mail_quota_by_job_level(job_level) # 產生臨時密碼 temp_password = self._generate_temporary_password() result = mailserver.create_email_account( email=email_address, password=temp_password, quota_mb=quota_mb, ) if result["created"]: logger.info(f"郵件帳號建立成功: {email_address} ({quota_mb}MB)") else: logger.warning(f"郵件帳號建立失敗: {email_address} - {result.get('error')}") return result except Exception as e: logger.warning(f"建立郵件帳號時發生非預期錯誤: {str(e)}") return { "created": False, "email": email_address, "quota_mb": 0, "message": "建立郵件帳號時發生錯誤", "error": str(e), } async def _create_drive_account(self, employee: Employee) -> Dict[str, Any]: """ 建立雲端硬碟帳號 (Drive Service) 呼叫 drive-api.ease.taipei 建立 Nextcloud 帳號 Drive Service 未上線時以 warning 記錄,不影響其他流程 """ try: client = get_drive_service_client() from app.core.config import settings # 根據職級取得配額 job_level = getattr(employee, "job_level", "Junior") or "Junior" quota_gb = get_drive_quota_by_job_level(job_level) result = client.create_user( tenant_id=settings.DRIVE_SERVICE_TENANT_ID, keycloak_user_id=str(getattr(employee, "keycloak_user_id", "") or ""), username=employee.username_base, email=f"{employee.username_base}@porscheworld.tw", display_name=employee.legal_name or employee.username_base, quota_gb=quota_gb, ) return result except Exception as e: logger.warning(f"建立雲端硬碟帳號時發生非預期錯誤: {str(e)}") return { "created": False, "username": employee.username_base, "quota_gb": 0, "drive_url": None, "message": "建立雲端硬碟帳號時發生錯誤", "error": str(e), } async def _disable_keycloak_account(self, employee: Employee) -> Dict[str, Any]: """ 停用 Keycloak SSO 帳號 執行步驟: 1. 查詢用戶 ID 2. 停用帳號 (不刪除,保留審計記錄) 注意: 不刪除帳號,只停用,以保留歷史記錄和審計追蹤 Args: employee: 員工物件 Returns: 執行結果字典 """ try: client = self._get_keycloak_client() username = employee.username_base # 1. 查詢用戶 user = client.get_user_by_username(username) if not user: return { "disabled": False, "username": username, "message": "Keycloak 帳號不存在", "error": "用戶不存在", } user_id = user.get("id") # 2. 檢查是否已停用 if not user.get("enabled", False): return { "disabled": True, "username": username, "user_id": user_id, "message": "Keycloak 帳號已經是停用狀態", "error": None, } # 3. 停用帳號 success = client.disable_user(user_id) if success: logger.info(f"✓ Keycloak 帳號停用成功: {username} (ID: {user_id})") return { "disabled": True, "username": username, "user_id": user_id, "message": "Keycloak 帳號已停用 (帳號保留以維持審計記錄)", "error": None, } else: return { "disabled": False, "username": username, "user_id": user_id, "message": "Keycloak 帳號停用失敗", "error": "API 調用失敗", } except Exception as e: logger.error(f"✗ 停用 Keycloak 帳號時發生錯誤: {str(e)}") return { "disabled": False, "username": employee.username_base, "message": "停用 Keycloak 帳號時發生錯誤", "error": str(e), } async def _handle_email_offboarding( self, employee: Employee, method: str ) -> Dict[str, Any]: """ 處理離職員工的郵件帳號 (Docker Mailserver) Args: method: "forward" - 停用帳號並標記轉寄 "disable" - 直接刪除郵件帳號 """ email_address = f"{employee.username_base}@porscheworld.tw" try: mailserver = get_mailserver_service() if method == "forward": # 刪除帳號 (Docker Mailserver 不支援原生轉寄設定) # 轉寄規則記錄在 EmailAccount.forward_to,由 HR Portal 管理 result = mailserver.delete_email_account(email_address) return { "handled": result["deleted"], "method": "forward", "email": email_address, "forward_to": "hr@porscheworld.tw", "message": "郵件帳號已停用,轉寄規則已記錄" if result["deleted"] else "郵件帳號停用失敗", "error": result.get("error"), } elif method == "disable": # 刪除郵件帳號 result = mailserver.delete_email_account(email_address) return { "handled": result["deleted"], "method": "disable", "email": email_address, "message": "郵件帳號已刪除" if result["deleted"] else "郵件帳號刪除失敗", "error": result.get("error"), } else: return { "handled": False, "method": method, "email": email_address, "error": f"不支援的處理方式: {method}", } except Exception as e: logger.warning(f"處理郵件帳號離職時發生非預期錯誤: {str(e)}") return { "handled": False, "method": method, "email": email_address, "message": "處理郵件帳號時發生錯誤", "error": str(e), } async def _disable_drive_account(self, employee: Employee) -> Dict[str, Any]: """ 停用雲端硬碟帳號 (Drive Service) 呼叫 drive-api.ease.taipei 停用 Nextcloud 帳號 (軟刪除,保留檔案) Drive Service 未上線時以 warning 記錄,不影響其他流程 """ try: client = get_drive_service_client() # 查詢 drive_user_id (目前以 username 查詢) # Drive Service 上線後需實作 GET /api/v1/drive/users?username={username} # 暫時回傳 warning 狀態 logger.warning( f"停用雲端硬碟帳號: {employee.username_base} - " f"需 Drive Service 上線後實作查詢 user_id 再停用" ) return { "disabled": False, "username": employee.username_base, "message": "Drive Service 尚未上線,雲端硬碟帳號停用待後續處理", "error": "Drive Service 未上線", } except Exception as e: logger.warning(f"停用雲端硬碟帳號時發生非預期錯誤: {str(e)}") return { "disabled": False, "username": employee.username_base, "message": "停用雲端硬碟帳號時發生錯誤", "error": str(e), } # 建立全域實例 employee_lifecycle_service = EmployeeLifecycleService() def get_employee_lifecycle_service() -> EmployeeLifecycleService: """取得員工生命週期服務實例""" return employee_lifecycle_service