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,146 @@
"""
Keycloak 同步批次 (5.2)
執行時間: 每日 03:00
批次名稱: sync_keycloak_users
同步 Keycloak 使用者狀態到 HR Portal
以 HR Portal 為準 (Single Source of Truth)
"""
import logging
from datetime import datetime
from app.batch.base import log_batch_execution
logger = logging.getLogger(__name__)
def run_sync_keycloak_users() -> dict:
"""
執行 Keycloak 同步批次
以 HR Portal 員工狀態為準,同步到 Keycloak:
- active → Keycloak enabled = True
- terminated/on_leave → Keycloak enabled = False
Returns:
執行結果摘要
"""
started_at = datetime.utcnow()
summary = {
"total_checked": 0,
"synced": 0,
"not_found_in_keycloak": 0,
"no_keycloak_id": 0,
"errors": 0,
}
issues = []
logger.info("=== 開始 Keycloak 同步批次 ===")
from app.db.session import get_db
from app.models.employee import Employee
from app.services.keycloak_admin_client import get_keycloak_admin_client
db = next(get_db())
try:
# 1. 取得所有員工
employees = db.query(Employee).all()
keycloak_client = get_keycloak_admin_client()
logger.info(f"{len(employees)} 位員工待檢查")
for emp in employees:
summary["total_checked"] += 1
# 跳過沒有 Keycloak ID 的員工 (尚未執行到職流程)
# 以 username_base 查詢 Keycloak
username = emp.username_base
if not username:
summary["no_keycloak_id"] += 1
continue
try:
# 2. 查詢 Keycloak 使用者
kc_user = keycloak_client.get_user_by_username(username)
if not kc_user:
# Keycloak 使用者不存在,可能尚未建立
summary["not_found_in_keycloak"] += 1
logger.debug(f"員工 {emp.employee_id} ({username}) 在 Keycloak 中不存在,跳過")
continue
kc_user_id = kc_user.get("id")
kc_enabled = kc_user.get("enabled", False)
# 3. 判斷應有的 enabled 狀態
should_be_enabled = (emp.status == "active")
# 4. 狀態不一致時,以 HR Portal 為準同步到 Keycloak
if kc_enabled != should_be_enabled:
success = keycloak_client.update_user(
kc_user_id, {"enabled": should_be_enabled}
)
if success:
summary["synced"] += 1
logger.info(
f"✓ 同步 {emp.employee_id} ({username}): "
f"Keycloak enabled {kc_enabled}{should_be_enabled} "
f"(HR 狀態: {emp.status})"
)
else:
summary["errors"] += 1
issues.append(f"{emp.employee_id}: 同步失敗")
logger.warning(f"✗ 同步 {emp.employee_id} ({username}) 失敗")
except Exception as e:
summary["errors"] += 1
issues.append(f"{emp.employee_id}: {str(e)}")
logger.error(f"處理員工 {emp.employee_id} 時發生錯誤: {e}")
# 5. 記錄批次執行日誌
finished_at = datetime.utcnow()
message = (
f"檢查: {summary['total_checked']}, "
f"同步: {summary['synced']}, "
f"Keycloak 無帳號: {summary['not_found_in_keycloak']}, "
f"錯誤: {summary['errors']}"
)
if issues:
message += f"\n問題清單: {'; '.join(issues[:10])}"
if len(issues) > 10:
message += f" ... 共 {len(issues)} 個問題"
status = "failed" if summary["errors"] > 0 else "success"
log_batch_execution(
batch_name="sync_keycloak_users",
status=status,
message=message,
started_at=started_at,
finished_at=finished_at,
)
logger.info(f"=== Keycloak 同步批次完成 === {message}")
return {"status": status, "summary": summary}
except Exception as e:
error_msg = f"Keycloak 同步批次失敗: {str(e)}"
logger.error(error_msg)
log_batch_execution(
batch_name="sync_keycloak_users",
status="failed",
message=error_msg,
started_at=started_at,
)
return {"status": "failed", "error": str(e)}
finally:
db.close()
if __name__ == "__main__":
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
logging.basicConfig(level=logging.INFO)
result = run_sync_keycloak_users()
print(f"執行結果: {result}")