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>
This commit is contained in:
10
backend/app/services/__init__.py
Normal file
10
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Services 模組
|
||||
匯出所有業務邏輯服務
|
||||
"""
|
||||
from app.services.audit_service import audit_service, AuditService
|
||||
|
||||
__all__ = [
|
||||
"audit_service",
|
||||
"AuditService",
|
||||
]
|
||||
257
backend/app/services/audit_service.py
Normal file
257
backend/app/services/audit_service.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
審計日誌服務
|
||||
自動記錄所有 CRUD 操作,符合 ISO 要求
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Request
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""審計日誌服務類別"""
|
||||
|
||||
@staticmethod
|
||||
def log(
|
||||
db: Session,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
performed_by: str,
|
||||
resource_id: Optional[int] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""
|
||||
創建審計日誌
|
||||
|
||||
Args:
|
||||
db: 資料庫 Session
|
||||
action: 操作類型 (create/update/delete/login/logout)
|
||||
resource_type: 資源類型 (employee/identity/department/etc)
|
||||
performed_by: 操作者 SSO 帳號
|
||||
resource_id: 資源 ID
|
||||
details: 詳細變更內容 (dict)
|
||||
ip_address: IP 位址
|
||||
|
||||
Returns:
|
||||
AuditLog: 創建的審計日誌物件
|
||||
"""
|
||||
# TODO: 從 JWT token 或 session 獲取 tenant_id,目前暫時使用預設值 1
|
||||
tenant_id = 1
|
||||
|
||||
audit_log = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
db.add(audit_log)
|
||||
db.commit()
|
||||
db.refresh(audit_log)
|
||||
|
||||
return audit_log
|
||||
|
||||
@staticmethod
|
||||
def log_create(
|
||||
db: Session,
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
performed_by: str,
|
||||
details: Dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""記錄創建操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="create",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_update(
|
||||
db: Session,
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
performed_by: str,
|
||||
old_values: Dict[str, Any],
|
||||
new_values: Dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""
|
||||
記錄更新操作
|
||||
|
||||
Args:
|
||||
old_values: 舊值
|
||||
new_values: 新值
|
||||
"""
|
||||
details = {
|
||||
"old": old_values,
|
||||
"new": new_values,
|
||||
"changed_fields": list(set(old_values.keys()) & set(new_values.keys()))
|
||||
}
|
||||
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="update",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_delete(
|
||||
db: Session,
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
performed_by: str,
|
||||
details: Dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""記錄刪除操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="delete",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_login(
|
||||
db: Session,
|
||||
username: str,
|
||||
ip_address: Optional[str] = None,
|
||||
success: bool = True,
|
||||
) -> AuditLog:
|
||||
"""記錄登入操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="login",
|
||||
resource_type="authentication",
|
||||
performed_by=username,
|
||||
details={"success": success},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_logout(
|
||||
db: Session,
|
||||
username: str,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""記錄登出操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="logout",
|
||||
resource_type="authentication",
|
||||
performed_by=username,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_client_ip(request: Request) -> Optional[str]:
|
||||
"""
|
||||
從 Request 獲取客戶端 IP
|
||||
|
||||
優先順序:
|
||||
1. X-Forwarded-For (代理服務器)
|
||||
2. X-Real-IP (Nginx)
|
||||
3. request.client.host (直接連接)
|
||||
"""
|
||||
# 檢查 X-Forwarded-For
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# 取第一個 IP (客戶端真實 IP)
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
# 檢查 X-Real-IP
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# 直接連接
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def model_to_dict(obj, exclude_fields: Optional[set] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
將 SQLAlchemy Model 轉換為 dict (用於審計日誌)
|
||||
|
||||
Args:
|
||||
obj: SQLAlchemy Model 物件
|
||||
exclude_fields: 排除的欄位集合
|
||||
|
||||
Returns:
|
||||
dict: 模型的 dict 表示
|
||||
"""
|
||||
if exclude_fields is None:
|
||||
exclude_fields = {"created_at", "updated_at", "_sa_instance_state"}
|
||||
|
||||
result = {}
|
||||
for column in obj.__table__.columns:
|
||||
if column.name not in exclude_fields:
|
||||
value = getattr(obj, column.name)
|
||||
# 處理 datetime
|
||||
if isinstance(value, datetime):
|
||||
value = value.isoformat()
|
||||
result[column.name] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@staticmethod
|
||||
def log_action(
|
||||
db: Session,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: Optional[int] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request: Optional[Request] = None,
|
||||
performed_by: str = "system",
|
||||
) -> AuditLog:
|
||||
"""
|
||||
通用操作記錄 (permissions.py 使用的介面)
|
||||
|
||||
Args:
|
||||
db: 資料庫 Session
|
||||
action: 操作類型
|
||||
resource_type: 資源類型
|
||||
resource_id: 資源 ID
|
||||
details: 詳細內容
|
||||
request: FastAPI Request 物件 (用於取得 IP)
|
||||
performed_by: 操作者
|
||||
"""
|
||||
ip_address = None
|
||||
if request is not None:
|
||||
ip_address = AuditService.get_client_ip(request)
|
||||
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
# 全域審計服務實例
|
||||
audit_service = AuditService()
|
||||
281
backend/app/services/drive_service.py
Normal file
281
backend/app/services/drive_service.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
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
|
||||
529
backend/app/services/employee_lifecycle.py
Normal file
529
backend/app/services/employee_lifecycle.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
員工生命週期管理服務
|
||||
自動化處理員工的新進、異動、離職流程
|
||||
"""
|
||||
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
|
||||
685
backend/app/services/environment_checker.py
Normal file
685
backend/app/services/environment_checker.py
Normal file
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
環境檢測服務
|
||||
自動檢測系統所需的所有環境組件
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
import psycopg2
|
||||
import requests
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
class EnvironmentChecker:
|
||||
"""環境檢測器"""
|
||||
|
||||
def __init__(self):
|
||||
self.results = {}
|
||||
|
||||
# ==================== Redis 檢測 ====================
|
||||
|
||||
def check_redis(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 Redis 服務
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"host": str,
|
||||
"port": int,
|
||||
"ping_success": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"host": None,
|
||||
"port": None,
|
||||
"ping_success": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 檢查環境變數
|
||||
redis_host = os.getenv("REDIS_HOST")
|
||||
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||
|
||||
if not redis_host:
|
||||
result["error"] = "REDIS_HOST 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["host"] = redis_host
|
||||
result["port"] = int(redis_port)
|
||||
|
||||
# 測試連線(需要 redis 套件)
|
||||
try:
|
||||
import redis
|
||||
redis_client = redis.Redis(
|
||||
host=redis_host,
|
||||
port=int(redis_port),
|
||||
password=os.getenv("REDIS_PASSWORD"),
|
||||
db=int(os.getenv("REDIS_DB", "0")),
|
||||
socket_connect_timeout=5,
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
# 測試 PING
|
||||
pong = redis_client.ping()
|
||||
if pong:
|
||||
result["available"] = True
|
||||
result["ping_success"] = True
|
||||
result["status"] = "ok"
|
||||
else:
|
||||
result["status"] = "error"
|
||||
result["error"] = "Redis PING 失敗"
|
||||
|
||||
redis_client.close()
|
||||
|
||||
except ImportError:
|
||||
result["status"] = "warning"
|
||||
result["error"] = "redis 套件未安裝(pip install redis)"
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = f"Redis 連線失敗: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def test_redis_connection(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
password: Optional[str] = None,
|
||||
db: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
測試 Redis 連線(用於初始化時使用者輸入的連線資訊)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"ping_success": bool,
|
||||
"message": str,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"ping_success": False,
|
||||
"message": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
import redis
|
||||
|
||||
redis_client = redis.Redis(
|
||||
host=host,
|
||||
port=port,
|
||||
password=password if password else None,
|
||||
db=db,
|
||||
socket_connect_timeout=5,
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
# 測試 PING
|
||||
pong = redis_client.ping()
|
||||
if pong:
|
||||
result["success"] = True
|
||||
result["ping_success"] = True
|
||||
result["message"] = "Redis 連線成功"
|
||||
else:
|
||||
result["error"] = "Redis PING 失敗"
|
||||
|
||||
redis_client.close()
|
||||
|
||||
except ImportError:
|
||||
result["error"] = "redis 套件未安裝"
|
||||
except redis.exceptions.AuthenticationError:
|
||||
result["error"] = "Redis 密碼錯誤"
|
||||
except redis.exceptions.ConnectionError as e:
|
||||
result["error"] = f"無法連接到 Redis: {str(e)}"
|
||||
except Exception as e:
|
||||
result["error"] = f"未知錯誤: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def check_all(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢查所有環境組件
|
||||
|
||||
Returns:
|
||||
完整的檢測報告
|
||||
"""
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"overall_status": "pending",
|
||||
"components": {
|
||||
"redis": self.check_redis(),
|
||||
"database": self.check_database(),
|
||||
"keycloak": self.check_keycloak(),
|
||||
"mailserver": self.check_mailserver(),
|
||||
"drive": self.check_drive_service(),
|
||||
"traefik": self.check_traefik(),
|
||||
"network": self.check_network(),
|
||||
},
|
||||
"missing_configs": self.get_missing_configs(),
|
||||
"recommendations": self.get_recommendations()
|
||||
}
|
||||
|
||||
# ==================== 資料庫檢測 ====================
|
||||
|
||||
def check_database(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 PostgreSQL 資料庫
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"connection_string": str,
|
||||
"version": str,
|
||||
"tables_exist": bool,
|
||||
"tenant_exists": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"connection_string": None,
|
||||
"version": None,
|
||||
"tables_exist": False,
|
||||
"tenant_exists": False,
|
||||
"tenant_initialized": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 1. 檢查環境變數
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if not db_url:
|
||||
result["error"] = "DATABASE_URL 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["connection_string"] = self._mask_password(db_url)
|
||||
|
||||
# 2. 測試連線
|
||||
try:
|
||||
engine = create_engine(db_url)
|
||||
with engine.connect() as conn:
|
||||
# 取得版本
|
||||
version_result = conn.execute(text("SELECT version()"))
|
||||
version_row = version_result.fetchone()
|
||||
if version_row:
|
||||
result["version"] = version_row[0].split(',')[0]
|
||||
|
||||
result["available"] = True
|
||||
|
||||
# 3. 檢查 tenants 表是否存在
|
||||
try:
|
||||
tenant_check = conn.execute(text(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'tenants')"
|
||||
))
|
||||
result["tables_exist"] = tenant_check.scalar()
|
||||
|
||||
if result["tables_exist"]:
|
||||
# 4. 檢查是否有租戶資料
|
||||
tenant_count = conn.execute(text("SELECT COUNT(*) FROM tenants"))
|
||||
count = tenant_count.scalar()
|
||||
result["tenant_exists"] = count > 0
|
||||
|
||||
if result["tenant_exists"]:
|
||||
# 5. 檢查租戶是否已初始化
|
||||
init_check = conn.execute(text(
|
||||
"SELECT is_initialized FROM tenants LIMIT 1"
|
||||
))
|
||||
is_init = init_check.scalar()
|
||||
result["tenant_initialized"] = is_init
|
||||
|
||||
except Exception as e:
|
||||
result["tables_exist"] = False
|
||||
result["error"] = f"資料表檢查失敗: {str(e)}"
|
||||
|
||||
# 判斷狀態
|
||||
if result["tenant_initialized"]:
|
||||
result["status"] = "ok"
|
||||
elif result["tenant_exists"]:
|
||||
result["status"] = "warning"
|
||||
elif result["tables_exist"]:
|
||||
result["status"] = "warning"
|
||||
else:
|
||||
result["status"] = "warning"
|
||||
|
||||
except Exception as e:
|
||||
result["available"] = False
|
||||
result["status"] = "error"
|
||||
result["error"] = f"資料庫連線失敗: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def test_database_connection(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
database: str,
|
||||
user: str,
|
||||
password: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
測試資料庫連線(用於初始化時使用者輸入的連線資訊)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"version": str,
|
||||
"message": str,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"version": None,
|
||||
"message": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# 使用 psycopg2 直接測試
|
||||
conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
database=database,
|
||||
user=user,
|
||||
password=password,
|
||||
connect_timeout=5
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT version()")
|
||||
version = cursor.fetchone()[0]
|
||||
result["version"] = version.split(',')[0]
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
result["success"] = True
|
||||
result["message"] = "資料庫連線成功"
|
||||
|
||||
except psycopg2.OperationalError as e:
|
||||
result["error"] = f"連線失敗: {str(e)}"
|
||||
except Exception as e:
|
||||
result["error"] = f"未知錯誤: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== Keycloak 檢測 ====================
|
||||
|
||||
def check_keycloak(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 Keycloak SSO 服務
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"url": str,
|
||||
"realm": str,
|
||||
"realm_exists": bool,
|
||||
"clients_configured": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"url": None,
|
||||
"realm": None,
|
||||
"realm_exists": False,
|
||||
"clients_configured": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 1. 檢查環境變數
|
||||
kc_url = os.getenv("KEYCLOAK_URL")
|
||||
kc_realm = os.getenv("KEYCLOAK_REALM")
|
||||
|
||||
if not kc_url:
|
||||
result["error"] = "KEYCLOAK_URL 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["url"] = kc_url
|
||||
result["realm"] = kc_realm or "未設定"
|
||||
|
||||
# 2. 測試 Keycloak 服務是否運行
|
||||
try:
|
||||
# 測試 health endpoint
|
||||
response = requests.get(f"{kc_url}/health", timeout=5)
|
||||
if response.status_code == 200:
|
||||
result["available"] = True
|
||||
else:
|
||||
result["available"] = False
|
||||
result["error"] = f"Keycloak 服務異常: HTTP {response.status_code}"
|
||||
result["status"] = "error"
|
||||
return result
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
result["available"] = False
|
||||
result["status"] = "error"
|
||||
result["error"] = f"無法連接到 Keycloak: {str(e)}"
|
||||
return result
|
||||
|
||||
# 3. 檢查 Realm 是否存在
|
||||
if kc_realm:
|
||||
try:
|
||||
# 嘗試取得 Realm 的 OpenID Configuration
|
||||
oidc_url = f"{kc_url}/realms/{kc_realm}/.well-known/openid-configuration"
|
||||
response = requests.get(oidc_url, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
result["realm_exists"] = True
|
||||
result["status"] = "ok"
|
||||
else:
|
||||
result["realm_exists"] = False
|
||||
result["status"] = "warning"
|
||||
result["error"] = f"Realm '{kc_realm}' 不存在"
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = f"Realm 檢查失敗: {str(e)}"
|
||||
result["status"] = "warning"
|
||||
else:
|
||||
result["status"] = "warning"
|
||||
result["error"] = "KEYCLOAK_REALM 未設定"
|
||||
|
||||
return result
|
||||
|
||||
def test_keycloak_connection(
|
||||
self,
|
||||
url: str,
|
||||
realm: str,
|
||||
admin_username: str,
|
||||
admin_password: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
測試 Keycloak 連線並驗證管理員權限
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"realm_exists": bool,
|
||||
"admin_access": bool,
|
||||
"message": str,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"realm_exists": False,
|
||||
"admin_access": False,
|
||||
"message": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. 測試服務是否運行 (使用根路徑,Keycloak 會返回 302 重定向)
|
||||
health_response = requests.get(f"{url}/", timeout=5, allow_redirects=False)
|
||||
if health_response.status_code not in [200, 302, 303]:
|
||||
result["error"] = "Keycloak 服務未運行"
|
||||
return result
|
||||
|
||||
# 2. 測試管理員登入
|
||||
token_url = f"{url}/realms/master/protocol/openid-connect/token"
|
||||
token_data = {
|
||||
"grant_type": "password",
|
||||
"client_id": "admin-cli",
|
||||
"username": admin_username,
|
||||
"password": admin_password
|
||||
}
|
||||
|
||||
token_response = requests.post(token_url, data=token_data, timeout=10)
|
||||
|
||||
if token_response.status_code == 200:
|
||||
result["admin_access"] = True
|
||||
access_token = token_response.json().get("access_token")
|
||||
|
||||
# 3. 檢查 Realm 是否存在
|
||||
realm_url = f"{url}/admin/realms/{realm}"
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
realm_response = requests.get(realm_url, headers=headers, timeout=5)
|
||||
|
||||
if realm_response.status_code == 200:
|
||||
result["realm_exists"] = True
|
||||
result["success"] = True
|
||||
result["message"] = "Keycloak 連線成功,Realm 存在"
|
||||
elif realm_response.status_code == 404:
|
||||
result["success"] = True
|
||||
result["message"] = "Keycloak 連線成功,但 Realm 不存在(將自動建立)"
|
||||
else:
|
||||
result["error"] = f"Realm 檢查失敗: HTTP {realm_response.status_code}"
|
||||
|
||||
else:
|
||||
result["error"] = "管理員帳號密碼錯誤"
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
result["error"] = f"連線失敗: {str(e)}"
|
||||
except Exception as e:
|
||||
result["error"] = f"未知錯誤: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 郵件伺服器檢測 ====================
|
||||
|
||||
def check_mailserver(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測郵件伺服器 (Docker Mailserver)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"ssh_configured": bool,
|
||||
"container_running": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"ssh_configured": False,
|
||||
"container_running": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 檢查 SSH 設定
|
||||
ssh_host = os.getenv("MAILSERVER_SSH_HOST")
|
||||
ssh_user = os.getenv("MAILSERVER_SSH_USER")
|
||||
container_name = os.getenv("MAILSERVER_CONTAINER_NAME")
|
||||
|
||||
if not all([ssh_host, ssh_user, container_name]):
|
||||
result["error"] = "郵件伺服器 SSH 設定不完整"
|
||||
return result
|
||||
|
||||
result["ssh_configured"] = True
|
||||
|
||||
# 測試 SSH 連線(可選功能)
|
||||
# 注意:這需要 paramiko 套件,且需要謹慎處理安全性
|
||||
result["status"] = "warning"
|
||||
result["error"] = "郵件伺服器連線測試需要手動驗證"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 雲端硬碟檢測 ====================
|
||||
|
||||
def check_drive_service(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測雲端硬碟服務 (Nextcloud)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"url": str,
|
||||
"api_accessible": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"url": None,
|
||||
"api_accessible": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
drive_url = os.getenv("DRIVE_SERVICE_URL")
|
||||
|
||||
if not drive_url:
|
||||
result["error"] = "DRIVE_SERVICE_URL 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["url"] = drive_url
|
||||
|
||||
try:
|
||||
response = requests.get(f"{drive_url}/status.php", timeout=5)
|
||||
if response.status_code == 200:
|
||||
result["available"] = True
|
||||
result["api_accessible"] = True
|
||||
result["status"] = "ok"
|
||||
else:
|
||||
result["status"] = "warning"
|
||||
result["error"] = f"Drive 服務回應異常: HTTP {response.status_code}"
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = f"無法連接到 Drive 服務: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== Traefik 檢測 ====================
|
||||
|
||||
def check_traefik(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 Traefik 反向代理
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "not_configured",
|
||||
"dashboard_accessible": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"dashboard_accessible": False,
|
||||
"error": "Traefik 檢測未實作(需要 Dashboard URL)"
|
||||
}
|
||||
|
||||
# 簡化檢測:Traefik 通常在本機運行
|
||||
# 可以透過檢查 port 80/443 是否被占用來判斷
|
||||
result["status"] = "ok"
|
||||
result["dashboard_accessible"] = False
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 網路檢測 ====================
|
||||
|
||||
def check_network(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測網路連通性
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning",
|
||||
"dns_resolution": bool,
|
||||
"ports_open": dict,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "ok",
|
||||
"dns_resolution": True,
|
||||
"ports_open": {
|
||||
"80": False,
|
||||
"443": False,
|
||||
"5433": False
|
||||
},
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 檢查常用 port 是否開啟
|
||||
ports_to_check = [80, 443, 5433]
|
||||
|
||||
for port in ports_to_check:
|
||||
result["ports_open"][str(port)] = self._is_port_open("localhost", port)
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 輔助方法 ====================
|
||||
|
||||
def _is_port_open(self, host: str, port: int, timeout: int = 2) -> bool:
|
||||
"""檢查 port 是否開啟"""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
result = sock.connect_ex((host, port))
|
||||
sock.close()
|
||||
return result == 0
|
||||
except:
|
||||
return False
|
||||
|
||||
def _mask_password(self, connection_string: str) -> str:
|
||||
"""遮蔽連線字串中的密碼"""
|
||||
import re
|
||||
return re.sub(r'://([^:]+):([^@]+)@', r'://\1:****@', connection_string)
|
||||
|
||||
def get_missing_configs(self) -> List[str]:
|
||||
"""取得缺少的環境變數"""
|
||||
required_vars = [
|
||||
"DATABASE_URL",
|
||||
"KEYCLOAK_URL",
|
||||
"KEYCLOAK_REALM",
|
||||
"KEYCLOAK_CLIENT_ID",
|
||||
"KEYCLOAK_CLIENT_SECRET",
|
||||
]
|
||||
|
||||
missing = []
|
||||
for var in required_vars:
|
||||
if not os.getenv(var):
|
||||
missing.append(var)
|
||||
|
||||
return missing
|
||||
|
||||
def get_recommendations(self) -> List[str]:
|
||||
"""根據檢測結果提供建議"""
|
||||
recommendations = []
|
||||
|
||||
# 這裡可以根據檢測結果動態產生建議
|
||||
if not os.getenv("DATABASE_URL"):
|
||||
recommendations.append("請先設定資料庫連線資訊")
|
||||
|
||||
if not os.getenv("KEYCLOAK_URL"):
|
||||
recommendations.append("請設定 Keycloak SSO 服務")
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 測試環境檢測
|
||||
checker = EnvironmentChecker()
|
||||
report = checker.check_all()
|
||||
|
||||
print("=== 環境檢測報告 ===\n")
|
||||
for component, result in report["components"].items():
|
||||
status_icon = {
|
||||
"ok": "✓",
|
||||
"warning": "⚠",
|
||||
"error": "✗",
|
||||
"not_configured": "○"
|
||||
}.get(result["status"], "?")
|
||||
|
||||
print(f"{status_icon} {component.upper()}: {result['status']}")
|
||||
if result.get("error"):
|
||||
print(f" 錯誤: {result['error']}")
|
||||
|
||||
print(f"\n缺少的配置: {', '.join(report['missing_configs']) or '無'}")
|
||||
789
backend/app/services/installation_service.py
Normal file
789
backend/app/services/installation_service.py
Normal file
@@ -0,0 +1,789 @@
|
||||
"""
|
||||
初始化系統服務
|
||||
負責初始化流程的業務邏輯
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
InstallationSession,
|
||||
InstallationTenantInfo,
|
||||
InstallationDepartmentSetup,
|
||||
TemporaryPassword,
|
||||
InstallationAccessLog,
|
||||
Tenant,
|
||||
Department,
|
||||
UserRole
|
||||
)
|
||||
from app.models.emp_resume import EmpResume
|
||||
from app.models.emp_setting import EmpSetting
|
||||
from app.utils.password_generator import generate_secure_password, hash_password
|
||||
from app.services.keycloak_service import KeycloakService
|
||||
|
||||
|
||||
class InstallationService:
|
||||
"""初始化服務"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.keycloak_service = KeycloakService()
|
||||
|
||||
# ==================== Phase 0: 建立安裝會話 ====================
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
tenant_id: int,
|
||||
environment: str,
|
||||
executed_by: str,
|
||||
session_name: Optional[str] = None
|
||||
) -> InstallationSession:
|
||||
"""
|
||||
建立新的安裝會話
|
||||
|
||||
Args:
|
||||
tenant_id: 租戶 ID
|
||||
environment: 環境 (development/testing/production)
|
||||
executed_by: 執行人
|
||||
session_name: 會話名稱(可選)
|
||||
|
||||
Returns:
|
||||
安裝會話物件
|
||||
"""
|
||||
session = InstallationSession(
|
||||
tenant_id=tenant_id,
|
||||
session_name=session_name or f"{environment} 環境初始化",
|
||||
environment=environment,
|
||||
status='in_progress',
|
||||
executed_by=executed_by,
|
||||
started_at=datetime.now()
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
|
||||
# 記錄審計日誌
|
||||
self._log_access(
|
||||
session_id=session.id,
|
||||
action='create_session',
|
||||
action_by=executed_by,
|
||||
action_method='api',
|
||||
access_granted=True
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
# ==================== Phase 2: 公司資訊設定 ====================
|
||||
|
||||
def save_tenant_info(
|
||||
self,
|
||||
session_id: int,
|
||||
tenant_info_data: Dict[str, Any]
|
||||
) -> InstallationTenantInfo:
|
||||
"""
|
||||
儲存租戶初始化資訊
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
tenant_info_data: 租戶資訊字典
|
||||
|
||||
Returns:
|
||||
租戶初始化資訊物件
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
# 檢查是否已存在(優先用 session_id,找不到則用 tenant_id)
|
||||
tenant_info = self.db.query(InstallationTenantInfo).filter_by(
|
||||
session_id=session_id
|
||||
).first()
|
||||
|
||||
# 如果 session_id 找不到,嘗試用 tenant_id 查詢(處理舊數據)
|
||||
if not tenant_info:
|
||||
tenant_info = self.db.query(InstallationTenantInfo).filter_by(
|
||||
tenant_id=session.tenant_id
|
||||
).first()
|
||||
|
||||
if tenant_info:
|
||||
# 更新現有資料(同時更新 session_id 以保持一致性)
|
||||
tenant_info.session_id = session_id
|
||||
for key, value in tenant_info_data.items():
|
||||
if hasattr(tenant_info, key):
|
||||
setattr(tenant_info, key, value)
|
||||
tenant_info.updated_at = datetime.now()
|
||||
else:
|
||||
# 建立新資料
|
||||
tenant_info = InstallationTenantInfo(
|
||||
tenant_id=session.tenant_id,
|
||||
session_id=session_id,
|
||||
**tenant_info_data
|
||||
)
|
||||
self.db.add(tenant_info)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(tenant_info)
|
||||
|
||||
return tenant_info
|
||||
|
||||
def setup_admin_credentials(
|
||||
self,
|
||||
session_id: int,
|
||||
admin_data: Dict[str, Any],
|
||||
password_method: str = 'auto',
|
||||
manual_password: Optional[str] = None
|
||||
) -> tuple[InstallationTenantInfo, str]:
|
||||
"""
|
||||
設定系統管理員並產生初始密碼
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
admin_data: 管理員資訊
|
||||
password_method: 密碼設定方式 (auto/manual)
|
||||
manual_password: 手動設定的密碼(如果 method='manual')
|
||||
|
||||
Returns:
|
||||
(租戶資訊物件, 明文密碼)
|
||||
|
||||
Raises:
|
||||
ValueError: 如果密碼驗證失敗
|
||||
"""
|
||||
from app.utils.password_generator import validate_password_for_user
|
||||
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
# 產生或驗證密碼
|
||||
if password_method == 'auto':
|
||||
initial_password = generate_secure_password(16)
|
||||
else:
|
||||
if not manual_password:
|
||||
raise ValueError("手動設定密碼時必須提供 manual_password")
|
||||
|
||||
# 驗證密碼強度
|
||||
is_valid, errors = validate_password_for_user(
|
||||
manual_password,
|
||||
username=admin_data.get('admin_username'),
|
||||
name=admin_data.get('admin_legal_name'),
|
||||
email=admin_data.get('admin_email')
|
||||
)
|
||||
if not is_valid:
|
||||
raise ValueError(f"密碼驗證失敗: {', '.join(errors)}")
|
||||
|
||||
initial_password = manual_password
|
||||
|
||||
# 加密密碼
|
||||
password_hash = hash_password(initial_password)
|
||||
|
||||
# 儲存管理員資訊
|
||||
tenant_info = self.save_tenant_info(session_id, admin_data)
|
||||
|
||||
# 建立臨時密碼記錄
|
||||
temp_password = TemporaryPassword(
|
||||
tenant_id=session.tenant_id,
|
||||
username=admin_data.get('admin_english_name', 'admin'), # ✅ 使用 admin_english_name (SSO 帳號)
|
||||
session_id=session_id,
|
||||
password_hash=password_hash,
|
||||
plain_password=initial_password, # 明文密碼(僅此階段保存)
|
||||
password_method=password_method,
|
||||
is_temporary=True,
|
||||
must_change_on_login=True,
|
||||
created_at=datetime.now(),
|
||||
expires_at=datetime.now() + timedelta(days=7), # 7 天有效期
|
||||
is_viewable=True,
|
||||
viewable_until=datetime.now() + timedelta(hours=1) # 1 小時內可查看
|
||||
)
|
||||
self.db.add(temp_password)
|
||||
self.db.commit()
|
||||
|
||||
return tenant_info, initial_password
|
||||
|
||||
# ==================== Phase 3: 組織架構設定 ====================
|
||||
|
||||
def setup_departments(
|
||||
self,
|
||||
session_id: int,
|
||||
departments_data: List[Dict[str, Any]]
|
||||
) -> List[InstallationDepartmentSetup]:
|
||||
"""
|
||||
設定部門架構
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
departments_data: 部門資訊列表
|
||||
|
||||
Returns:
|
||||
部門設定物件列表
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
dept_setups = []
|
||||
for dept_data in departments_data:
|
||||
dept_setup = InstallationDepartmentSetup(
|
||||
tenant_id=session.tenant_id,
|
||||
session_id=session_id,
|
||||
**dept_data
|
||||
)
|
||||
self.db.add(dept_setup)
|
||||
dept_setups.append(dept_setup)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return dept_setups
|
||||
|
||||
# ==================== Phase 4: 執行初始化 ====================
|
||||
|
||||
def execute_initialization(
|
||||
self,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
執行完整的初始化流程
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
|
||||
Returns:
|
||||
執行結果
|
||||
|
||||
Raises:
|
||||
Exception: 如果任何步驟失敗
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
tenant_info = self.db.query(InstallationTenantInfo).filter_by(
|
||||
session_id=session_id
|
||||
).first()
|
||||
if not tenant_info:
|
||||
# 調試:查看資料庫中所有的 tenant_info 記錄
|
||||
all_tenant_infos = self.db.query(InstallationTenantInfo).all()
|
||||
tenant_info_list = [f"ID:{t.id}, SessionID:{t.session_id}, TenantID:{t.tenant_id}" for t in all_tenant_infos]
|
||||
raise ValueError(
|
||||
f"找不到租戶初始化資訊 (session_id={session_id})。"
|
||||
f"資料庫中現有記錄: {tenant_info_list}"
|
||||
)
|
||||
|
||||
results = {
|
||||
'tenant_updated': False,
|
||||
'departments_created': 0,
|
||||
'admin_created': False,
|
||||
'keycloak_user_created': False,
|
||||
'mailbox_created': False,
|
||||
'roles_assigned': False
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: 建立或更新租戶基本資料
|
||||
if session.tenant_id:
|
||||
# 更新現有租戶
|
||||
tenant = self.db.query(Tenant).get(session.tenant_id)
|
||||
if not tenant:
|
||||
raise ValueError(f"找不到租戶 ID: {session.tenant_id}")
|
||||
else:
|
||||
# 建立新租戶 (初始化流程)
|
||||
# ⚠️ 租戶代碼和 Keycloak Realm 必須為小寫
|
||||
tenant_code_lower = tenant_info.tenant_code.lower()
|
||||
|
||||
tenant = Tenant(
|
||||
name=tenant_info.company_name,
|
||||
name_eng=tenant_info.company_name_en,
|
||||
code=tenant_code_lower,
|
||||
keycloak_realm=tenant_code_lower, # Keycloak Realm = tenant_code (小寫)
|
||||
prefix=tenant_info.tenant_prefix,
|
||||
tax_id=tenant_info.tax_id,
|
||||
tel=tenant_info.tel,
|
||||
add=tenant_info.add,
|
||||
domain_set=tenant_info.domain_set,
|
||||
domain=tenant_info.domain,
|
||||
is_sysmana=True, # 初始化建立的第一個租戶為系統管理公司
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(tenant)
|
||||
self.db.flush() # 取得 tenant.id
|
||||
|
||||
# 更新 session 的 tenant_id
|
||||
session.tenant_id = tenant.id
|
||||
tenant_info.tenant_id = tenant.id
|
||||
|
||||
# 更新租戶資料 (如果是更新模式)
|
||||
if session.tenant_id and tenant:
|
||||
if tenant_info.company_name:
|
||||
tenant.name = tenant_info.company_name
|
||||
if tenant_info.company_name_en:
|
||||
tenant.name_eng = tenant_info.company_name_en
|
||||
if tenant_info.tenant_code:
|
||||
tenant.code = tenant_info.tenant_code
|
||||
if tenant_info.tenant_prefix:
|
||||
tenant.prefix = tenant_info.tenant_prefix
|
||||
if tenant_info.tax_id:
|
||||
tenant.tax_id = tenant_info.tax_id
|
||||
if tenant_info.tel:
|
||||
tenant.tel = tenant_info.tel
|
||||
if tenant_info.add:
|
||||
tenant.add = tenant_info.add
|
||||
if tenant_info.domain_set:
|
||||
tenant.domain_set = tenant_info.domain_set
|
||||
if tenant_info.domain:
|
||||
tenant.domain = tenant_info.domain
|
||||
|
||||
tenant.is_initialized = True
|
||||
tenant.initialized_at = datetime.now()
|
||||
tenant.initialized_by = session.executed_by
|
||||
self.db.commit()
|
||||
results['tenant_updated'] = True
|
||||
|
||||
# Step 2: 建立「初始化部門」(每個租戶必備)
|
||||
init_dept_exists = self.db.query(Department).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
code='INIT'
|
||||
).first()
|
||||
|
||||
init_dept = None
|
||||
if not init_dept_exists:
|
||||
# 決定初始化部門的 email_domain
|
||||
# domain_set=1 (組織網域): 使用 tenant.domain
|
||||
# domain_set=2 (部門網域): 使用 tenant_info.domain 作為初始化部門的網域
|
||||
if tenant_info.domain_set == 1:
|
||||
# 組織網域模式: 必須設定 domain
|
||||
if not tenant_info.domain:
|
||||
raise ValueError("組織網域模式必須設定網域")
|
||||
init_dept_domain = tenant_info.domain
|
||||
else:
|
||||
# 部門網域模式: domain 是初始化部門的預設網域
|
||||
if not tenant_info.domain:
|
||||
raise ValueError("請設定初始化部門的預設網域")
|
||||
init_dept_domain = tenant_info.domain
|
||||
|
||||
init_dept = Department(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1, # 第一個部門序號為 1
|
||||
code='INIT',
|
||||
name='初始化部門',
|
||||
name_en='Initialization Department',
|
||||
email_domain=init_dept_domain,
|
||||
depth=0,
|
||||
description='系統初始化專用部門,待組織架構建立完成後可刪除或保留',
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(init_dept)
|
||||
self.db.commit()
|
||||
self.db.refresh(init_dept)
|
||||
results['departments_created'] += 1
|
||||
|
||||
# Step 2.1: 建立用戶自訂的其他部門 (如果有)
|
||||
dept_setups = self.db.query(InstallationDepartmentSetup).filter_by(
|
||||
session_id=session_id,
|
||||
is_created=False
|
||||
).all()
|
||||
|
||||
for dept_setup in dept_setups:
|
||||
dept = Department(
|
||||
tenant_id=session.tenant_id,
|
||||
code=dept_setup.department_code,
|
||||
name=dept_setup.department_name,
|
||||
name_en=dept_setup.department_name_en,
|
||||
email_domain=dept_setup.email_domain,
|
||||
depth=dept_setup.depth,
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(dept)
|
||||
dept_setup.is_created = True
|
||||
results['departments_created'] += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# Step 3: 建立系統管理員員工 (歸屬於初始化部門)
|
||||
# 取得初始化部門 ID
|
||||
if not init_dept:
|
||||
init_dept = self.db.query(Department).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
code='INIT'
|
||||
).first()
|
||||
|
||||
if not init_dept:
|
||||
raise ValueError("找不到初始化部門,無法建立管理員")
|
||||
|
||||
# 決定 SSO 帳號名稱:使用英文名稱(第一個管理員不會有衝突)
|
||||
sso_username = tenant_info.admin_english_name
|
||||
if not sso_username:
|
||||
raise ValueError("管理員英文名稱為必填項")
|
||||
|
||||
# 檢查是否已存在(使用 tenant_emp_settings 複合主鍵)
|
||||
admin_exists = self.db.query(EmpSetting).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1 # 第一個員工
|
||||
).first()
|
||||
|
||||
if not admin_exists:
|
||||
# Step 3-1: 建立人員基本資料 (EmpResume)
|
||||
resume = EmpResume(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1, # 第一個員工序號為 1
|
||||
legal_name=tenant_info.admin_legal_name,
|
||||
english_name=tenant_info.admin_english_name,
|
||||
id_number=f"INIT{session.tenant_id}001", # 初始化用臨時身分證號
|
||||
mobile=tenant_info.admin_phone,
|
||||
personal_email=tenant_info.admin_email,
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(resume)
|
||||
self.db.commit()
|
||||
self.db.refresh(resume)
|
||||
results['resumes_created'] = 1
|
||||
|
||||
# Step 3-2: 建立員工任用設定 (EmpSetting) - 複合主鍵
|
||||
emp_setting = EmpSetting(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1, # 第一個員工(或由觸發器自動生成)
|
||||
tenant_resume_id=resume.id,
|
||||
tenant_emp_code=f"{tenant_info.tenant_prefix}0001", # 或由觸發器自動生成
|
||||
hire_at=datetime.now().date(),
|
||||
employment_type='full_time',
|
||||
employment_status='active',
|
||||
primary_dept_id=init_dept.id,
|
||||
storage_quota_gb=100, # 管理員預設 100GB
|
||||
email_quota_mb=10240, # 管理員預設 10GB
|
||||
tenant_keycloak_username=sso_username, # 優先使用英文名稱,有衝突時使用 admin_username
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(emp_setting)
|
||||
self.db.commit()
|
||||
self.db.refresh(emp_setting)
|
||||
results['emp_settings_created'] = 1
|
||||
|
||||
# 決定郵件網域:使用初始化部門的網域
|
||||
if not init_dept.email_domain:
|
||||
raise ValueError("初始化部門未設定郵件網域,請檢查設定")
|
||||
|
||||
# 管理員郵件地址:使用 SSO 帳號名稱 + 網域
|
||||
admin_email_address = f"{sso_username}@{init_dept.email_domain}"
|
||||
|
||||
# Step 4: 建立 Keycloak 用戶
|
||||
temp_password = self.db.query(TemporaryPassword).filter_by(
|
||||
session_id=session_id,
|
||||
is_used=False
|
||||
).first()
|
||||
|
||||
if temp_password and temp_password.plain_password:
|
||||
# 建立 Keycloak 用戶(同時設定臨時密碼)
|
||||
user_id = self.keycloak_service.create_user(
|
||||
username=sso_username, # 使用英文名稱作為 SSO 帳號
|
||||
email=admin_email_address,
|
||||
first_name=tenant_info.admin_legal_name.split()[0] if tenant_info.admin_legal_name else '',
|
||||
last_name=tenant_info.admin_legal_name.split()[-1] if len(tenant_info.admin_legal_name.split()) > 1 else '',
|
||||
enabled=True,
|
||||
temporary_password=temp_password.plain_password # ✅ 設定臨時密碼,強制首次登入修改
|
||||
)
|
||||
|
||||
# 檢查 Keycloak 用戶是否建立成功
|
||||
if not user_id:
|
||||
raise ValueError(f"Keycloak 用戶建立失敗: {sso_username}")
|
||||
|
||||
# 更新員工任用設定的 Keycloak User ID
|
||||
emp_setting.tenant_keycloak_user_id = user_id
|
||||
|
||||
# 標記臨時密碼已使用(但保留明文密碼供用戶記錄)
|
||||
temp_password.is_used = True
|
||||
temp_password.used_at = datetime.now()
|
||||
# ⚠️ 不立即清除明文密碼,保留給用戶記錄
|
||||
# temp_password.plain_password = None
|
||||
# temp_password.plain_password_cleared_at = datetime.now()
|
||||
# temp_password.cleared_reason = 'keycloak_created'
|
||||
|
||||
self.db.commit()
|
||||
results['keycloak_user_created'] = True
|
||||
|
||||
# Step 4.5: 建立郵件帳號 (Docker Mailserver)
|
||||
try:
|
||||
from app.services.mailserver_service import MailserverService
|
||||
from app.utils.password_generator import generate_secure_password
|
||||
|
||||
mailserver = MailserverService()
|
||||
|
||||
# 為管理員建立郵件帳號
|
||||
# 郵件地址已在 Step 3 決定: admin_email_address
|
||||
# 郵件密碼:如果臨時密碼還有明文則使用,否則自動生成新密碼
|
||||
mail_password = temp_password.plain_password if (temp_password and temp_password.plain_password) else generate_secure_password()
|
||||
|
||||
mail_result = mailserver.create_email_account(
|
||||
email=admin_email_address,
|
||||
password=mail_password,
|
||||
quota_mb=emp_setting.email_quota_mb
|
||||
)
|
||||
|
||||
if mail_result.get('success'):
|
||||
results['mailbox_created'] = True
|
||||
print(f"[OK] 郵件帳號建立成功: {admin_email_address}")
|
||||
else:
|
||||
# 郵件建立失敗僅記錄 warning,不中斷初始化流程
|
||||
print(f"[WARNING] 郵件帳號建立失敗: {mail_result.get('error', 'Unknown error')}")
|
||||
results['mailbox_created'] = False
|
||||
results['mailbox_error'] = mail_result.get('error')
|
||||
|
||||
except Exception as mail_error:
|
||||
# 郵件系統錯誤不應中斷初始化流程
|
||||
print(f"[WARNING] 郵件系統整合失敗: {str(mail_error)}")
|
||||
results['mailbox_created'] = False
|
||||
results['mailbox_error'] = str(mail_error)
|
||||
|
||||
# Step 5: 分配系統管理員角色
|
||||
sys_admin_role = self.db.query(UserRole).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
role_code='SYS_ADMIN'
|
||||
).first()
|
||||
|
||||
if sys_admin_role:
|
||||
from app.models import UserRoleAssignment
|
||||
role_assignment = UserRoleAssignment(
|
||||
tenant_id=session.tenant_id,
|
||||
keycloak_user_id=emp_setting.tenant_keycloak_user_id,
|
||||
role_id=sys_admin_role.id,
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(role_assignment)
|
||||
self.db.commit()
|
||||
results['roles_assigned'] = True
|
||||
|
||||
# 標記初始化完成並自動鎖定
|
||||
session.status = 'completed'
|
||||
session.completed_at = datetime.now()
|
||||
session.completed_steps = 5
|
||||
session.is_locked = True
|
||||
session.locked_at = datetime.now()
|
||||
session.locked_by = 'system'
|
||||
session.lock_reason = '初始化完成自動鎖定'
|
||||
|
||||
tenant_info.is_completed = True
|
||||
tenant_info.completed_at = datetime.now()
|
||||
tenant_info.completed_by = session.executed_by
|
||||
|
||||
# 更新系統狀態:從 initialization → operational
|
||||
from app.models.installation import InstallationSystemStatus
|
||||
system_status = self.db.query(InstallationSystemStatus).filter_by(id=1).first()
|
||||
if system_status:
|
||||
system_status.previous_phase = system_status.current_phase
|
||||
system_status.current_phase = 'operational'
|
||||
system_status.phase_changed_at = datetime.now()
|
||||
system_status.phase_changed_by = session.executed_by or 'installer'
|
||||
system_status.phase_change_reason = '初始化完成,系統進入正式運作階段'
|
||||
system_status.initialization_completed = True
|
||||
system_status.initialized_at = datetime.now()
|
||||
system_status.initialized_by = session.executed_by or 'installer'
|
||||
system_status.operational_since = datetime.now()
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# 記錄鎖定日誌
|
||||
self._log_access(
|
||||
session_id=session_id,
|
||||
action='lock',
|
||||
action_by='system',
|
||||
action_method='auto',
|
||||
access_granted=True
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
session.status = 'failed'
|
||||
self.db.commit()
|
||||
raise Exception(f"初始化執行失敗: {str(e)}")
|
||||
|
||||
# ==================== 明文密碼管理 ====================
|
||||
|
||||
def clear_plain_password(
|
||||
self,
|
||||
session_id: int,
|
||||
reason: str = 'user_confirmed'
|
||||
) -> bool:
|
||||
"""
|
||||
清除臨時密碼的明文
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
reason: 清除原因
|
||||
|
||||
Returns:
|
||||
是否成功清除
|
||||
"""
|
||||
temp_passwords = self.db.query(TemporaryPassword).filter_by(
|
||||
session_id=session_id,
|
||||
is_used=False
|
||||
).filter(
|
||||
TemporaryPassword.plain_password.isnot(None)
|
||||
).all()
|
||||
|
||||
for temp_pwd in temp_passwords:
|
||||
temp_pwd.plain_password = None
|
||||
temp_pwd.plain_password_cleared_at = datetime.now()
|
||||
temp_pwd.cleared_reason = reason
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return len(temp_passwords) > 0
|
||||
|
||||
# ==================== 存取控制 ====================
|
||||
|
||||
def check_session_access(
|
||||
self,
|
||||
session_id: int,
|
||||
action: str,
|
||||
action_by: str
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
檢查是否可以存取安裝會話的敏感資訊
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
action: 動作 (view/download_pdf)
|
||||
action_by: 操作人
|
||||
|
||||
Returns:
|
||||
(是否允許, 拒絕原因)
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
return False, "安裝會話不存在"
|
||||
|
||||
# 檢查鎖定狀態
|
||||
if session.is_locked:
|
||||
# 檢查臨時解鎖是否有效
|
||||
if session.unlock_expires_at and session.unlock_expires_at > datetime.now():
|
||||
return True, None
|
||||
else:
|
||||
return False, "會話已鎖定"
|
||||
|
||||
return True, None
|
||||
|
||||
def _log_access(
|
||||
self,
|
||||
session_id: int,
|
||||
action: str,
|
||||
action_by: str,
|
||||
action_method: str,
|
||||
access_granted: bool,
|
||||
deny_reason: Optional[str] = None,
|
||||
sensitive_data_accessed: Optional[List[str]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> InstallationAccessLog:
|
||||
"""
|
||||
記錄存取日誌
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
action: 動作
|
||||
action_by: 操作人
|
||||
action_method: 操作方式
|
||||
access_granted: 是否允許
|
||||
deny_reason: 拒絕原因
|
||||
sensitive_data_accessed: 存取的敏感資料
|
||||
ip_address: IP 位址
|
||||
user_agent: User Agent
|
||||
|
||||
Returns:
|
||||
存取日誌物件
|
||||
"""
|
||||
log = InstallationAccessLog(
|
||||
session_id=session_id,
|
||||
action=action,
|
||||
action_by=action_by,
|
||||
action_method=action_method,
|
||||
access_granted=access_granted,
|
||||
deny_reason=deny_reason,
|
||||
sensitive_data_accessed=sensitive_data_accessed,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
self.db.add(log)
|
||||
self.db.commit()
|
||||
return log
|
||||
|
||||
# ==================== 查詢功能 ====================
|
||||
|
||||
def get_session_details(
|
||||
self,
|
||||
session_id: int,
|
||||
include_sensitive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取得安裝會話詳細資訊
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
include_sensitive: 是否包含敏感資訊(需檢查存取權限)
|
||||
|
||||
Returns:
|
||||
會話詳細資訊
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
result = {
|
||||
"session": {
|
||||
"id": session.id,
|
||||
"session_name": session.session_name,
|
||||
"environment": session.environment,
|
||||
"status": session.status,
|
||||
"started_at": session.started_at,
|
||||
"completed_at": session.completed_at,
|
||||
"is_locked": session.is_locked,
|
||||
"locked_at": session.locked_at,
|
||||
"lock_reason": session.lock_reason,
|
||||
"unlock_expires_at": session.unlock_expires_at
|
||||
},
|
||||
"tenant_info": None,
|
||||
"departments": [],
|
||||
"credentials": None
|
||||
}
|
||||
|
||||
# 租戶資訊
|
||||
tenant_info = session.tenant_info
|
||||
if tenant_info:
|
||||
result["tenant_info"] = {
|
||||
"company_name": tenant_info.company_name,
|
||||
"company_name_en": tenant_info.company_name_en,
|
||||
"tax_id": tenant_info.tax_id,
|
||||
"admin_username": tenant_info.admin_username,
|
||||
"admin_email": tenant_info.admin_email,
|
||||
"admin_legal_name": tenant_info.admin_legal_name
|
||||
}
|
||||
|
||||
# 部門設定
|
||||
result["departments"] = [
|
||||
{
|
||||
"code": d.department_code,
|
||||
"name": d.department_name,
|
||||
"name_en": d.department_name_en,
|
||||
"email_domain": d.email_domain,
|
||||
"is_created": d.is_created
|
||||
}
|
||||
for d in session.department_setups
|
||||
]
|
||||
|
||||
# 敏感資訊(密碼)
|
||||
if include_sensitive and not session.is_locked:
|
||||
temp_password = self.db.query(TemporaryPassword).filter_by(
|
||||
session_id=session_id
|
||||
).first()
|
||||
|
||||
if temp_password:
|
||||
result["credentials"] = {
|
||||
"password_visible": temp_password.plain_password is not None,
|
||||
"plain_password": temp_password.plain_password,
|
||||
"password_hash": temp_password.password_hash,
|
||||
"created_at": temp_password.created_at,
|
||||
"expires_at": temp_password.expires_at,
|
||||
"view_count": temp_password.view_count,
|
||||
"cleared_at": temp_password.plain_password_cleared_at,
|
||||
"cleared_reason": temp_password.cleared_reason
|
||||
}
|
||||
|
||||
return result
|
||||
816
backend/app/services/keycloak_admin_client.py
Normal file
816
backend/app/services/keycloak_admin_client.py
Normal file
@@ -0,0 +1,816 @@
|
||||
"""
|
||||
Keycloak Admin REST API 客戶端
|
||||
直接使用 REST API,避免 python-keycloak 套件的版本兼容性問題
|
||||
"""
|
||||
import requests
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class KeycloakAdminClient:
|
||||
"""Keycloak Admin REST API 客戶端"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化客戶端"""
|
||||
self.server_url = settings.KEYCLOAK_URL
|
||||
self.realm = settings.KEYCLOAK_REALM
|
||||
self.admin_username = settings.KEYCLOAK_ADMIN_USERNAME
|
||||
self.admin_password = settings.KEYCLOAK_ADMIN_PASSWORD
|
||||
self._access_token: Optional[str] = None
|
||||
|
||||
def _get_admin_token(self) -> Optional[str]:
|
||||
"""
|
||||
獲取 Admin 訪問令牌
|
||||
|
||||
Returns:
|
||||
str: Access Token, 失敗返回 None
|
||||
"""
|
||||
try:
|
||||
token_url = f"{self.server_url}/realms/master/protocol/openid-connect/token"
|
||||
|
||||
data = {
|
||||
"client_id": "admin-cli",
|
||||
"username": self.admin_username,
|
||||
"password": self.admin_password,
|
||||
"grant_type": "password",
|
||||
}
|
||||
|
||||
response = requests.post(token_url, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data.get("access_token")
|
||||
return self._access_token
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get admin token: {e}")
|
||||
return None
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
獲取請求標頭
|
||||
|
||||
Returns:
|
||||
dict: 包含 Authorization 的標頭
|
||||
"""
|
||||
if not self._access_token:
|
||||
self._get_admin_token()
|
||||
|
||||
return {
|
||||
"Authorization": f"Bearer {self._access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def get_users(self, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
獲取用戶列表
|
||||
|
||||
Args:
|
||||
query: 查詢參數 (username, email, first, max, etc.)
|
||||
|
||||
Returns:
|
||||
list: 用戶列表
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users"
|
||||
params = query or {}
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# 如果是 401,重新獲取 token 並重試
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get users: {e}")
|
||||
return []
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根據用戶名獲取用戶
|
||||
|
||||
Args:
|
||||
username: 用戶名稱
|
||||
|
||||
Returns:
|
||||
dict: 用戶資料, 不存在返回 None
|
||||
"""
|
||||
users = self.get_users({"username": username, "exact": True})
|
||||
return users[0] if users else None
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根據 ID 獲取用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
dict: 用戶資料
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
enabled: bool = True,
|
||||
email_verified: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
創建用戶
|
||||
|
||||
Args:
|
||||
username: 用戶名稱
|
||||
email: 郵件地址
|
||||
first_name: 名字
|
||||
last_name: 姓氏
|
||||
enabled: 是否啟用
|
||||
email_verified: 郵件是否已驗證
|
||||
|
||||
Returns:
|
||||
str: User ID (成功時), None (失敗時)
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users"
|
||||
|
||||
user_data = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"enabled": enabled,
|
||||
"emailVerified": email_verified,
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# Keycloak 在 Location header 返回新用戶的 URL
|
||||
location = response.headers.get("Location", "")
|
||||
user_id = location.split("/")[-1] if location else None
|
||||
|
||||
print(f"✓ Created user: {username} (ID: {user_id})")
|
||||
return user_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create user {username}: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f" Response: {e.response.text}")
|
||||
return None
|
||||
|
||||
def update_user(self, user_id: str, user_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
更新用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
user_data: 要更新的資料
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}"
|
||||
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Updated user: {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to update user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def enable_user(self, user_id: str) -> bool:
|
||||
"""啟用用戶"""
|
||||
return self.update_user(user_id, {"enabled": True})
|
||||
|
||||
def disable_user(self, user_id: str) -> bool:
|
||||
"""停用用戶"""
|
||||
return self.update_user(user_id, {"enabled": False})
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
刪除用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}"
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Deleted user: {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to delete user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def reset_password(
|
||||
self,
|
||||
user_id: str,
|
||||
password: str,
|
||||
temporary: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
重設密碼
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
password: 新密碼
|
||||
temporary: 是否為臨時密碼
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}/reset-password"
|
||||
|
||||
credential = {
|
||||
"type": "password",
|
||||
"value": password,
|
||||
"temporary": temporary,
|
||||
}
|
||||
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=credential,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=credential,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Reset password for user: {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to reset password for {user_id}: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Realm Management ====================
|
||||
|
||||
def create_realm(
|
||||
self,
|
||||
realm_name: str,
|
||||
display_name: str,
|
||||
enabled: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
建立新的 Keycloak Realm (僅限 Superuser)
|
||||
|
||||
Args:
|
||||
realm_name: Realm 識別碼 (例: porscheworld-pwd)
|
||||
display_name: 顯示名稱 (例: Porsche World)
|
||||
enabled: 是否啟用
|
||||
|
||||
Returns:
|
||||
dict: Realm 配置資訊, 失敗返回 None
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms"
|
||||
|
||||
realm_config = {
|
||||
"realm": realm_name,
|
||||
"displayName": display_name,
|
||||
"enabled": enabled,
|
||||
"sslRequired": "external",
|
||||
"registrationAllowed": False, # 不允許自助註冊
|
||||
"loginWithEmailAllowed": True,
|
||||
"duplicateEmailsAllowed": False,
|
||||
"resetPasswordAllowed": True,
|
||||
"editUsernameAllowed": False,
|
||||
"bruteForceProtected": True,
|
||||
"permanentLockout": False,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"failureFactor": 5,
|
||||
# Token 設定
|
||||
"accessTokenLifespan": 1800, # 30 分鐘
|
||||
"ssoSessionIdleTimeout": 3600, # 1 小時
|
||||
"ssoSessionMaxLifespan": 36000, # 10 小時
|
||||
"offlineSessionIdleTimeout": 2592000, # 30 天
|
||||
# 國際化設定
|
||||
"internationalizationEnabled": True,
|
||||
"supportedLocales": ["zh-TW", "en"],
|
||||
"defaultLocale": "zh-TW",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=realm_config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=realm_config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Created realm: {realm_name}")
|
||||
return realm_config
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create realm {realm_name}: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f" Response: {e.response.text}")
|
||||
return None
|
||||
|
||||
def get_realm(self, realm_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
取得 Realm 配置
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
|
||||
Returns:
|
||||
dict: Realm 配置資訊
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get realm {realm_name}: {e}")
|
||||
return None
|
||||
|
||||
def update_realm(self, realm_name: str, config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
更新 Realm 配置
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
config: 要更新的配置
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}"
|
||||
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Updated realm: {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to update realm {realm_name}: {e}")
|
||||
return False
|
||||
|
||||
def delete_realm(self, realm_name: str) -> bool:
|
||||
"""
|
||||
刪除 Realm (危險操作,僅限 Superuser)
|
||||
|
||||
⚠️ WARNING: 此操作會刪除 Realm 中所有使用者、角色、客戶端等資料
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}"
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Deleted realm: {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to delete realm {realm_name}: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Realm Role Management ====================
|
||||
|
||||
def create_realm_role(
|
||||
self,
|
||||
realm_name: str,
|
||||
role_name: str,
|
||||
description: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
在指定 Realm 建立角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
role_name: 角色名稱
|
||||
description: 角色說明
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles"
|
||||
|
||||
role_data = {
|
||||
"name": role_name,
|
||||
"description": description or f"Role: {role_name}",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Created realm role: {role_name} in {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create realm role {role_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_realm_roles(self, realm_name: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
取得 Realm 所有角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
|
||||
Returns:
|
||||
list: 角色列表
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get realm roles for {realm_name}: {e}")
|
||||
return []
|
||||
|
||||
def delete_realm_role(self, realm_name: str, role_name: str) -> bool:
|
||||
"""
|
||||
刪除 Realm 角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles/{role_name}"
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Deleted realm role: {role_name} from {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to delete realm role {role_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_realm_role_by_name(self, realm_name: str, role_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
取得指定 Realm 角色的詳細資訊
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
dict: 角色資訊 (包含 id, name, description 等)
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles/{role_name}"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get realm role {role_name}: {e}")
|
||||
return None
|
||||
|
||||
def assign_realm_role_to_user(
|
||||
self,
|
||||
realm_name: str,
|
||||
user_id: str,
|
||||
role_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
將 Realm 角色分配給使用者
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
user_id: Keycloak User ID
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
# Step 1: 取得角色詳細資訊 (需要 role id)
|
||||
role = self.get_realm_role_by_name(realm_name, role_name)
|
||||
if not role:
|
||||
print(f"✗ Role {role_name} not found in realm {realm_name}")
|
||||
return False
|
||||
|
||||
# Step 2: 分配角色給使用者
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm"
|
||||
|
||||
# Keycloak 要求傳入角色的完整資訊 (id, name 等)
|
||||
role_mapping = [{
|
||||
"id": role["id"],
|
||||
"name": role["name"],
|
||||
}]
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Assigned role '{role_name}' to user {user_id} in realm {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to assign role {role_name} to user {user_id}: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f" Response: {e.response.text}")
|
||||
return False
|
||||
|
||||
def remove_realm_role_from_user(
|
||||
self,
|
||||
realm_name: str,
|
||||
user_id: str,
|
||||
role_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
從使用者移除 Realm 角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
user_id: Keycloak User ID
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
# Step 1: 取得角色詳細資訊
|
||||
role = self.get_realm_role_by_name(realm_name, role_name)
|
||||
if not role:
|
||||
print(f"✗ Role {role_name} not found in realm {realm_name}")
|
||||
return False
|
||||
|
||||
# Step 2: 從使用者移除角色
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm"
|
||||
|
||||
role_mapping = [{
|
||||
"id": role["id"],
|
||||
"name": role["name"],
|
||||
}]
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Removed role '{role_name}' from user {user_id} in realm {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to remove role {role_name} from user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_user_realm_roles(self, realm_name: str, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
取得使用者的所有 Realm 角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
list: 角色列表
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get user roles for {user_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# 全域實例 (延遲初始化)
|
||||
_keycloak_admin_client: Optional[KeycloakAdminClient] = None
|
||||
|
||||
|
||||
def get_keycloak_admin_client() -> KeycloakAdminClient:
|
||||
"""獲取 Keycloak Admin 客戶端實例 (單例)"""
|
||||
global _keycloak_admin_client
|
||||
if _keycloak_admin_client is None:
|
||||
_keycloak_admin_client = KeycloakAdminClient()
|
||||
return _keycloak_admin_client
|
||||
332
backend/app/services/keycloak_service.py
Normal file
332
backend/app/services/keycloak_service.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Keycloak SSO 整合服務
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from keycloak import KeycloakAdmin, KeycloakOpenID
|
||||
from keycloak.exceptions import KeycloakError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class KeycloakService:
|
||||
"""Keycloak 服務類別"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 Keycloak 連線"""
|
||||
self.server_url = settings.KEYCLOAK_URL
|
||||
self.realm_name = settings.KEYCLOAK_REALM
|
||||
self.client_id = settings.KEYCLOAK_CLIENT_ID
|
||||
self.client_secret = settings.KEYCLOAK_CLIENT_SECRET
|
||||
self._admin = None
|
||||
self._openid = None
|
||||
|
||||
@property
|
||||
def admin(self) -> Optional[KeycloakAdmin]:
|
||||
"""延遲初始化 Keycloak Admin 客戶端"""
|
||||
if self._admin is None and settings.KEYCLOAK_ADMIN_USERNAME and settings.KEYCLOAK_ADMIN_PASSWORD:
|
||||
try:
|
||||
# Keycloak 26.x 需要完整的 server_url (不含 /auth)
|
||||
self._admin = KeycloakAdmin(
|
||||
server_url=self.server_url,
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=self.realm_name,
|
||||
user_realm_name="master", # Admin 登入的 realm (通常是 master)
|
||||
verify=True,
|
||||
timeout=10 # 設定 10 秒超時
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to initialize Keycloak Admin: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return self._admin
|
||||
|
||||
@property
|
||||
def openid(self) -> KeycloakOpenID:
|
||||
"""延遲初始化 Keycloak OpenID 客戶端"""
|
||||
if self._openid is None:
|
||||
self._openid = KeycloakOpenID(
|
||||
server_url=self.server_url,
|
||||
client_id=self.client_id,
|
||||
realm_name=self.realm_name,
|
||||
client_secret_key=self.client_secret
|
||||
)
|
||||
return self._openid
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
enabled: bool = True,
|
||||
email_verified: bool = False,
|
||||
temporary_password: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
創建 Keycloak 用戶
|
||||
|
||||
Args:
|
||||
username: 用戶名稱 (username_base@email_domain)
|
||||
email: 郵件地址 (同 username)
|
||||
first_name: 名字
|
||||
last_name: 姓氏
|
||||
enabled: 是否啟用
|
||||
email_verified: 郵件是否已驗證
|
||||
temporary_password: 臨時密碼 (用戶首次登入需修改)
|
||||
|
||||
Returns:
|
||||
str: Keycloak User ID (UUID), 失敗返回 None
|
||||
"""
|
||||
if not self.admin:
|
||||
print("Error: Keycloak Admin not initialized")
|
||||
return None
|
||||
|
||||
try:
|
||||
# 創建用戶
|
||||
user_data = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"enabled": enabled,
|
||||
"emailVerified": email_verified,
|
||||
}
|
||||
|
||||
# 如果提供臨時密碼
|
||||
if temporary_password:
|
||||
user_data["credentials"] = [{
|
||||
"type": "password",
|
||||
"value": temporary_password,
|
||||
"temporary": True # 用戶首次登入需修改
|
||||
}]
|
||||
|
||||
user_id = self.admin.create_user(user_data)
|
||||
print(f"[OK] Keycloak user created: {username} (ID: {user_id})")
|
||||
return user_id
|
||||
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to create Keycloak user {username}: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根據用戶名獲取用戶資訊
|
||||
|
||||
Args:
|
||||
username: 用戶名稱
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊, 不存在返回 None
|
||||
"""
|
||||
if not self.admin:
|
||||
return None
|
||||
|
||||
try:
|
||||
users = self.admin.get_users({"username": username})
|
||||
if users:
|
||||
return users[0]
|
||||
return None
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to get user {username}: {e}")
|
||||
return None
|
||||
|
||||
def update_user(self, user_id: str, user_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
更新用戶資訊
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
user_data: 要更新的用戶資料
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
if not self.admin:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.admin.update_user(user_id, user_data)
|
||||
print(f"[OK] Keycloak user updated: {user_id}")
|
||||
return True
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to update user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def disable_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
停用用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
return self.update_user(user_id, {"enabled": False})
|
||||
|
||||
def enable_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
啟用用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
return self.update_user(user_id, {"enabled": True})
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
刪除用戶
|
||||
|
||||
注意: 這是實際刪除,建議使用 disable_user 進行軟刪除
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
if not self.admin:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.admin.delete_user(user_id)
|
||||
print(f"[OK] Keycloak user deleted: {user_id}")
|
||||
return True
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to delete user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def reset_password(
|
||||
self,
|
||||
user_id: str,
|
||||
new_password: str,
|
||||
temporary: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
重設用戶密碼
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
new_password: 新密碼
|
||||
temporary: 是否為臨時密碼 (用戶首次登入需修改)
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
if not self.admin:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.admin.set_user_password(
|
||||
user_id,
|
||||
new_password,
|
||||
temporary=temporary
|
||||
)
|
||||
print(f"[OK] Password reset for user: {user_id}")
|
||||
return True
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to reset password for {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
驗證 JWT Token
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
dict: Token payload (包含用戶資訊), 無效返回 None
|
||||
"""
|
||||
try:
|
||||
# python-keycloak 會自動從 Keycloak 獲取公鑰並驗證
|
||||
token_info = self.openid.decode_token(
|
||||
token,
|
||||
validate=True # 驗證簽名和過期時間
|
||||
)
|
||||
|
||||
return token_info
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
def get_user_info_from_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
從 Token 獲取用戶資訊
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊
|
||||
"""
|
||||
token_info = self.verify_token(token)
|
||||
if not token_info:
|
||||
return None
|
||||
|
||||
return {
|
||||
"username": token_info.get("preferred_username"),
|
||||
"email": token_info.get("email"),
|
||||
"first_name": token_info.get("given_name"),
|
||||
"last_name": token_info.get("family_name"),
|
||||
"sub": token_info.get("sub"), # Keycloak User ID
|
||||
"iss": token_info.get("iss"), # Issuer (用於多租戶)
|
||||
"realm_access": token_info.get("realm_access"), # 角色資訊
|
||||
}
|
||||
|
||||
def introspect_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
檢查 Token 狀態
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
dict: Token 資訊 (包含 active 狀態)
|
||||
"""
|
||||
try:
|
||||
return self.openid.introspect(token)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Token introspection failed: {e}")
|
||||
return None
|
||||
|
||||
def is_token_active(self, token: str) -> bool:
|
||||
"""
|
||||
檢查 Token 是否有效
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
bool: 有效返回 True
|
||||
"""
|
||||
introspection = self.introspect_token(token)
|
||||
if not introspection:
|
||||
return False
|
||||
return introspection.get("active", False)
|
||||
|
||||
|
||||
# 全域 Keycloak 服務實例
|
||||
# keycloak_service = KeycloakService()
|
||||
|
||||
# 延遲初始化服務實例
|
||||
_keycloak_service_instance: Optional[KeycloakService] = None
|
||||
|
||||
def get_keycloak_service() -> KeycloakService:
|
||||
"""獲取 Keycloak 服務實例 (單例)"""
|
||||
global _keycloak_service_instance
|
||||
if _keycloak_service_instance is None:
|
||||
_keycloak_service_instance = KeycloakService()
|
||||
return _keycloak_service_instance
|
||||
|
||||
# 模擬屬性訪問
|
||||
class _KeycloakServiceProxy:
|
||||
def __getattr__(self, name):
|
||||
return getattr(get_keycloak_service(), name)
|
||||
|
||||
keycloak_service = _KeycloakServiceProxy()
|
||||
245
backend/app/services/mailserver_service.py
Normal file
245
backend/app/services/mailserver_service.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Docker Mailserver Service
|
||||
透過 SSH + docker exec 管理 Docker Mailserver 郵件帳號
|
||||
|
||||
部署架構:
|
||||
HR Portal (10.1.0.245 或正式環境) --SSH--> Ubuntu Server (10.1.0.254)
|
||||
Ubuntu Server --> docker exec mailserver setup ...
|
||||
|
||||
整合方式:
|
||||
paramiko SSH → docker exec mailserver setup email add/del/quota set
|
||||
|
||||
失敗處理原則:
|
||||
- SSH 連線失敗以 warning 記錄,回傳包含 error 的結果字典
|
||||
- 不拋出例外,不影響 Keycloak 等其他 onboarding 流程
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 郵件配額設定 (MB),依職級對應
|
||||
MAIL_QUOTA_BY_JOB_LEVEL = {
|
||||
"Junior": 2048, # 2 GB
|
||||
"Mid": 3072, # 3 GB
|
||||
"Senior": 5120, # 5 GB
|
||||
"Manager": 10240, # 10 GB
|
||||
}
|
||||
|
||||
|
||||
def get_mail_quota_by_job_level(job_level: str) -> int:
|
||||
"""根據職級取得郵件配額 (MB)"""
|
||||
return MAIL_QUOTA_BY_JOB_LEVEL.get(job_level, MAIL_QUOTA_BY_JOB_LEVEL["Junior"])
|
||||
|
||||
|
||||
class MailserverService:
|
||||
"""
|
||||
Docker Mailserver 管理 Service
|
||||
|
||||
透過 SSH 連線到 Ubuntu Server (10.1.0.254),
|
||||
再執行 docker exec mailserver setup 指令管理郵件帳號。
|
||||
|
||||
支援操作:
|
||||
- 建立郵件帳號
|
||||
- 設定配額
|
||||
- 停用帳號 (停止收信,保留資料)
|
||||
- 設定轉寄
|
||||
- 查詢帳號狀態
|
||||
"""
|
||||
|
||||
def __init__(self, ssh_host: str, ssh_port: int, ssh_user: str, ssh_password: str,
|
||||
container_name: str = "mailserver", timeout: int = 30):
|
||||
self.ssh_host = ssh_host
|
||||
self.ssh_port = ssh_port
|
||||
self.ssh_user = ssh_user
|
||||
self.ssh_password = ssh_password
|
||||
self.container_name = container_name
|
||||
self.timeout = timeout
|
||||
|
||||
def _exec_docker_command(self, *setup_args) -> tuple[bool, str, str]:
|
||||
"""
|
||||
透過 SSH 執行 docker exec mailserver setup 指令
|
||||
|
||||
Args:
|
||||
*setup_args: setup 子指令參數
|
||||
例如: "email", "add", "user@domain.com", "password"
|
||||
|
||||
Returns:
|
||||
(success: bool, stdout: str, stderr: str)
|
||||
"""
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
logger.error("缺少 paramiko 套件,請執行: pip install paramiko")
|
||||
return False, "", "缺少 paramiko 套件"
|
||||
|
||||
cmd = f"docker exec {self.container_name} setup " + " ".join(
|
||||
f'"{arg}"' if " " in str(arg) else str(arg) for arg in setup_args
|
||||
)
|
||||
logger.debug(f"執行 Mailserver 指令: docker exec {self.container_name} setup {' '.join(str(a) for a in setup_args[:2])} ...")
|
||||
|
||||
ssh = None
|
||||
try:
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(
|
||||
hostname=self.ssh_host,
|
||||
port=self.ssh_port,
|
||||
username=self.ssh_user,
|
||||
password=self.ssh_password,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=self.timeout)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
rc = stdout.channel.recv_exit_status()
|
||||
|
||||
success = (rc == 0)
|
||||
if not success:
|
||||
logger.warning(f"Mailserver 指令失敗 (rc={rc}): {err or out}")
|
||||
return success, out, err
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"SSH 連線到 {self.ssh_host} 失敗: {e}")
|
||||
return False, "", str(e)
|
||||
finally:
|
||||
if ssh:
|
||||
try:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_email_account(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
quota_mb: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
建立郵件帳號
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup email add <email> <password>
|
||||
docker exec mailserver setup quota set <email> <quota>M
|
||||
|
||||
Args:
|
||||
email: 郵件地址 (例: user@porscheworld.tw)
|
||||
password: 初始密碼 (建議後續透過 Keycloak SSO 管理)
|
||||
quota_mb: 配額 (MB)
|
||||
|
||||
Returns:
|
||||
{"created": bool, "email": str, "quota_mb": int, "message": str, "error": str|None}
|
||||
"""
|
||||
# 1. 建立帳號
|
||||
success, out, err = self._exec_docker_command(
|
||||
"email", "add", email, password
|
||||
)
|
||||
if not success:
|
||||
return {
|
||||
"created": False,
|
||||
"email": email,
|
||||
"quota_mb": quota_mb,
|
||||
"message": "建立郵件帳號失敗",
|
||||
"error": err or out,
|
||||
}
|
||||
|
||||
logger.info(f"Mailserver: 郵件帳號建立成功 {email}")
|
||||
|
||||
# 2. 設定配額
|
||||
self.set_quota(email, quota_mb)
|
||||
|
||||
return {
|
||||
"created": True,
|
||||
"email": email,
|
||||
"quota_mb": quota_mb,
|
||||
"message": f"郵件帳號建立成功 ({quota_mb}MB)",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
def set_quota(self, email: str, quota_mb: int) -> Dict[str, Any]:
|
||||
"""
|
||||
設定郵件配額
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup quota set <email> <quota>M
|
||||
|
||||
Returns:
|
||||
{"updated": bool, "email": str, "quota_mb": int, "error": str|None}
|
||||
"""
|
||||
success, out, err = self._exec_docker_command(
|
||||
"quota", "set", email, f"{quota_mb}M"
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Mailserver: 配額設定成功 {email} → {quota_mb}MB")
|
||||
else:
|
||||
logger.warning(f"Mailserver: 配額設定失敗 {email}: {err}")
|
||||
|
||||
return {
|
||||
"updated": success,
|
||||
"email": email,
|
||||
"quota_mb": quota_mb,
|
||||
"error": None if success else (err or out),
|
||||
}
|
||||
|
||||
def delete_email_account(self, email: str) -> Dict[str, Any]:
|
||||
"""
|
||||
刪除郵件帳號
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup email del <email>
|
||||
|
||||
Returns:
|
||||
{"deleted": bool, "email": str, "error": str|None}
|
||||
"""
|
||||
success, out, err = self._exec_docker_command(
|
||||
"email", "del", email
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Mailserver: 郵件帳號刪除成功 {email}")
|
||||
else:
|
||||
logger.warning(f"Mailserver: 郵件帳號刪除失敗 {email}: {err}")
|
||||
|
||||
return {
|
||||
"deleted": success,
|
||||
"email": email,
|
||||
"error": None if success else (err or out),
|
||||
}
|
||||
|
||||
def list_accounts(self) -> Dict[str, Any]:
|
||||
"""
|
||||
列出所有郵件帳號
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup email list
|
||||
|
||||
Returns:
|
||||
{"accounts": list[str], "error": str|None}
|
||||
"""
|
||||
success, out, err = self._exec_docker_command("email", "list")
|
||||
if success:
|
||||
accounts = [line.strip() for line in out.splitlines() if line.strip()]
|
||||
return {"accounts": accounts, "error": None}
|
||||
return {"accounts": [], "error": err or out}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 延遲初始化單例
|
||||
# ============================================================
|
||||
_mailserver_service: Optional[MailserverService] = None
|
||||
|
||||
|
||||
def get_mailserver_service() -> MailserverService:
|
||||
"""取得 MailserverService 單例 (延遲初始化)"""
|
||||
global _mailserver_service
|
||||
if _mailserver_service is None:
|
||||
from app.core.config import settings
|
||||
_mailserver_service = MailserverService(
|
||||
ssh_host=settings.MAILSERVER_SSH_HOST,
|
||||
ssh_port=settings.MAILSERVER_SSH_PORT,
|
||||
ssh_user=settings.MAILSERVER_SSH_USER,
|
||||
ssh_password=settings.MAILSERVER_SSH_PASSWORD,
|
||||
container_name=settings.MAILSERVER_CONTAINER_NAME,
|
||||
timeout=settings.MAILSERVER_SSH_TIMEOUT,
|
||||
)
|
||||
return _mailserver_service
|
||||
Reference in New Issue
Block a user