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:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
"""
Services 模組
匯出所有業務邏輯服務
"""
from app.services.audit_service import audit_service, AuditService
__all__ = [
"audit_service",
"AuditService",
]

View 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()

View 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

View 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

View 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 ''}")

View 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

View 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

View 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()

View 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