Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
282 lines
9.7 KiB
Python
282 lines
9.7 KiB
Python
"""
|
|
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
|