Files
hr-portal/backend/app/services/drive_service.py
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
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>
2026-02-23 20:12:43 +08:00

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