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,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