""" Drive Service HTTP Client 呼叫 drive-api.ease.taipei 的 RESTful API 管理 Nextcloud 雲端硬碟帳號 架構說明: - Drive Service 是獨立的微服務 (尚未部署) - 本 Client 以非致命方式處理失敗 (失敗只記錄 warning,不影響其他流程) - Drive Service 上線後自動生效,無需修改 HR Portal 核心邏輯 """ import logging from typing import Dict, Any, Optional import requests from requests.exceptions import ConnectionError, Timeout, RequestException logger = logging.getLogger(__name__) # Drive Service API 配額配置 (GB),與 NAS 配額相同 DRIVE_QUOTA_BY_JOB_LEVEL = { "Junior": 50, "Mid": 100, "Senior": 200, "Manager": 500, } class DriveServiceClient: """ Drive Service HTTP Client 透過 REST API 管理 Nextcloud 雲端硬碟帳號: - 創建帳號 (POST /api/v1/drive/users) - 查詢配額 (GET /api/v1/drive/users/{id}/quota) - 更新配額 (PUT /api/v1/drive/users/{id}/quota) - 停用帳號 (DELETE /api/v1/drive/users/{id}) 失敗處理原則: - Drive Service 未上線時,連線失敗以 warning 記錄 - 不拋出例外,回傳包含 error 的結果字典 - 不影響 Keycloak、郵件等其他 onboarding 流程 """ def __init__(self, base_url: str, timeout: int = 10): self.base_url = base_url.rstrip("/") self.timeout = timeout self.session = requests.Session() self.session.headers.update({ "Content-Type": "application/json", "Accept": "application/json", }) def _is_available(self) -> bool: """快速檢查 Drive Service 是否可用""" try: resp = self.session.get( f"{self.base_url}/health", timeout=3, ) return resp.status_code == 200 except (ConnectionError, Timeout): return False except RequestException: return False def create_user( self, tenant_id: int, keycloak_user_id: str, username: str, email: str, display_name: str, quota_gb: int, ) -> Dict[str, Any]: """ 創建 Nextcloud 帳號 POST /api/v1/drive/users Args: tenant_id: 租戶 ID keycloak_user_id: Keycloak UUID username: Nextcloud 使用者名稱 (username_base) email: 電子郵件 display_name: 顯示名稱 quota_gb: 配額 (GB) Returns: { "created": True/False, "user_id": int or None, "username": str, "quota_gb": int, "drive_url": str, "message": str, "error": str or None, } """ try: resp = self.session.post( f"{self.base_url}/api/v1/drive/users", json={ "tenant_id": tenant_id, "keycloak_user_id": keycloak_user_id, "nextcloud_username": username, "email": email, "display_name": display_name, "quota_gb": quota_gb, }, timeout=self.timeout, ) if resp.status_code == 201: data = resp.json() logger.info(f"Drive Service: 帳號建立成功 {username} ({quota_gb}GB)") return { "created": True, "user_id": data.get("id"), "username": username, "quota_gb": quota_gb, "drive_url": f"https://drive.ease.taipei", "message": f"雲端硬碟帳號建立成功 ({quota_gb}GB)", "error": None, } elif resp.status_code == 409: logger.warning(f"Drive Service: 帳號已存在 {username}") return { "created": False, "user_id": None, "username": username, "quota_gb": quota_gb, "drive_url": f"https://drive.ease.taipei", "message": "雲端硬碟帳號已存在", "error": "帳號已存在", } else: logger.warning(f"Drive Service: 建立帳號失敗 {username} - HTTP {resp.status_code}") return { "created": False, "user_id": None, "username": username, "quota_gb": quota_gb, "drive_url": None, "message": f"雲端硬碟帳號建立失敗 (HTTP {resp.status_code})", "error": resp.text[:200], } except (ConnectionError, Timeout): logger.warning(f"Drive Service 未上線或無法連線,跳過帳號建立: {username}") return { "created": False, "user_id": None, "username": username, "quota_gb": quota_gb, "drive_url": None, "message": "Drive Service 尚未上線,跳過雲端硬碟帳號建立", "error": "Drive Service 無法連線", } except RequestException as e: logger.warning(f"Drive Service 請求失敗: {e}") return { "created": False, "user_id": None, "username": username, "quota_gb": quota_gb, "drive_url": None, "message": "Drive Service 請求失敗", "error": str(e), } def get_quota(self, drive_user_id: int) -> Optional[Dict[str, Any]]: """ 查詢配額使用量 GET /api/v1/drive/users/{id}/quota Returns: { "quota_gb": float, "used_gb": float, "usage_percentage": float, "warning_threshold": bool, # >= 80% "alert_threshold": bool, # >= 95% } or None if Drive Service unavailable """ try: resp = self.session.get( f"{self.base_url}/api/v1/drive/users/{drive_user_id}/quota", timeout=self.timeout, ) if resp.status_code == 200: return resp.json() else: logger.warning(f"Drive Service: 查詢配額失敗 user_id={drive_user_id} - HTTP {resp.status_code}") return None except (ConnectionError, Timeout, RequestException): logger.warning(f"Drive Service 無法連線,無法查詢配額 user_id={drive_user_id}") return None def update_quota(self, drive_user_id: int, quota_gb: int) -> Dict[str, Any]: """ 更新配額 PUT /api/v1/drive/users/{id}/quota Returns: {"updated": True/False, "quota_gb": int, "error": str or None} """ try: resp = self.session.put( f"{self.base_url}/api/v1/drive/users/{drive_user_id}/quota", json={"quota_gb": quota_gb}, timeout=self.timeout, ) if resp.status_code == 200: logger.info(f"Drive Service: 配額更新成功 user_id={drive_user_id} -> {quota_gb}GB") return {"updated": True, "quota_gb": quota_gb, "error": None} else: logger.warning(f"Drive Service: 配額更新失敗 user_id={drive_user_id} - HTTP {resp.status_code}") return { "updated": False, "quota_gb": quota_gb, "error": f"HTTP {resp.status_code}", } except (ConnectionError, Timeout, RequestException) as e: logger.warning(f"Drive Service 無法連線,無法更新配額: {e}") return {"updated": False, "quota_gb": quota_gb, "error": "Drive Service 無法連線"} def disable_user(self, drive_user_id: int) -> Dict[str, Any]: """ 停用帳號 (軟刪除) DELETE /api/v1/drive/users/{id} Returns: {"disabled": True/False, "error": str or None} """ try: resp = self.session.delete( f"{self.base_url}/api/v1/drive/users/{drive_user_id}", timeout=self.timeout, ) if resp.status_code in (200, 204): logger.info(f"Drive Service: 帳號停用成功 user_id={drive_user_id}") return {"disabled": True, "error": None} else: logger.warning(f"Drive Service: 帳號停用失敗 user_id={drive_user_id} - HTTP {resp.status_code}") return { "disabled": False, "error": f"HTTP {resp.status_code}", } except (ConnectionError, Timeout, RequestException) as e: logger.warning(f"Drive Service 無法連線,無法停用帳號: {e}") return {"disabled": False, "error": "Drive Service 無法連線"} def get_drive_quota_by_job_level(job_level: str) -> int: """根據職級取得雲端硬碟配額 (GB)""" return DRIVE_QUOTA_BY_JOB_LEVEL.get(job_level, DRIVE_QUOTA_BY_JOB_LEVEL["Junior"]) # 延遲初始化單例 _drive_service_client: Optional[DriveServiceClient] = None def get_drive_service_client() -> DriveServiceClient: """取得 DriveServiceClient 單例 (延遲初始化)""" global _drive_service_client if _drive_service_client is None: from app.core.config import settings _drive_service_client = DriveServiceClient( base_url=settings.DRIVE_SERVICE_URL, timeout=settings.DRIVE_SERVICE_TIMEOUT, ) return _drive_service_client