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,468 @@
"""
員工到職流程 API (v3.1 多租戶架構)
使用關聯表方式管理部門、角色、服務
"""
from typing import List, Optional
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.db.session import get_db
from app.models.emp_resume import EmpResume
from app.models.emp_setting import EmpSetting
from app.models.department_member import DepartmentMember
from app.models.role import UserRoleAssignment
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
from app.models.personal_service import PersonalService
from app.schemas.response import MessageResponse
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
return 1
def get_current_user_id() -> str:
"""取得當前使用者 ID (暫時寫死, 未來從 JWT token sub 取得)"""
return "system-admin"
# ==================== Schemas ====================
class DepartmentAssignment(BaseModel):
"""部門分配"""
department_id: int
position: Optional[str] = None
membership_type: str = "permanent" # permanent/temporary/project
class OnboardingRequest(BaseModel):
"""到職請求"""
# 人員基本資料
resume_id: int # 已存在的 tenant_emp_resumes.id
# SSO 帳號資訊
keycloak_user_id: str # Keycloak UUID
keycloak_username: str # 登入帳號
# 任用資訊
hire_date: date
# 部門分配
departments: List[DepartmentAssignment]
# 角色分配
role_ids: List[int]
# 配額設定
storage_quota_gb: int = 20
email_quota_mb: int = 5120
# ==================== API Endpoints ====================
@router.post("/onboard", status_code=status.HTTP_201_CREATED)
def onboard_employee(
data: OnboardingRequest,
request: Request,
db: Session = Depends(get_db),
):
"""
完整員工到職流程
執行項目:
1. 建立員工任用設定 (tenant_emp_settings)
2. 分配部門歸屬 (tenant_dept_members)
3. 分配使用者角色 (tenant_user_role_assignments)
4. 啟用所有個人化服務 (tenant_emp_personal_service_settings)
範例:
{
"resume_id": 1,
"keycloak_user_id": "550e8400-e29b-41d4-a716-446655440000",
"keycloak_username": "wang.ming",
"hire_date": "2026-02-20",
"departments": [
{"department_id": 9, "position": "資深工程師", "membership_type": "permanent"},
{"department_id": 12, "position": "專案經理", "membership_type": "project"}
],
"role_ids": [1, 2],
"storage_quota_gb": 20,
"email_quota_mb": 5120
}
"""
tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
# Step 1: 檢查 resume 是否存在
resume = db.query(EmpResume).filter(
EmpResume.id == data.resume_id,
EmpResume.tenant_id == tenant_id
).first()
if not resume:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Resume ID {data.resume_id} not found"
)
# 檢查是否已有任用設定
existing_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.tenant_resume_id == data.resume_id,
EmpSetting.is_active == True
).first()
if existing_setting:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Employee already onboarded (emp_code: {existing_setting.tenant_emp_code})"
)
# Step 2: 建立員工任用設定 (seq_no 由觸發器自動生成)
emp_setting = EmpSetting(
tenant_id=tenant_id,
# seq_no 會由觸發器自動生成
tenant_resume_id=data.resume_id,
# tenant_emp_code 會由觸發器自動生成
tenant_keycloak_user_id=data.keycloak_user_id,
tenant_keycloak_username=data.keycloak_username,
hire_at=data.hire_date,
storage_quota_gb=data.storage_quota_gb,
email_quota_mb=data.email_quota_mb,
employment_status="active",
is_active=True,
edit_by=current_user
)
db.add(emp_setting)
db.flush() # 取得自動生成的 seq_no 和 tenant_emp_code
# Step 3: 分配部門歸屬
dept_count = 0
for dept_assignment in data.departments:
dept_member = DepartmentMember(
tenant_id=tenant_id,
employee_id=data.resume_id, # 使用 resume_id 作為 employee_id
department_id=dept_assignment.department_id,
position=dept_assignment.position,
membership_type=dept_assignment.membership_type,
joined_at=datetime.utcnow(),
assigned_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(dept_member)
dept_count += 1
# Step 4: 分配使用者角色
role_count = 0
for role_id in data.role_ids:
role_assignment = UserRoleAssignment(
tenant_id=tenant_id,
keycloak_user_id=data.keycloak_user_id,
role_id=role_id,
assigned_at=datetime.utcnow(),
assigned_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(role_assignment)
role_count += 1
# Step 5: 啟用所有個人化服務
all_services = db.query(PersonalService).filter(
PersonalService.is_active == True
).all()
service_count = 0
for service in all_services:
# 根據服務類型設定配額
quota_gb = data.storage_quota_gb if service.service_code == "Drive" else None
quota_mb = data.email_quota_mb if service.service_code == "Email" else None
service_setting = EmpPersonalServiceSetting(
tenant_id=tenant_id,
tenant_keycloak_user_id=data.keycloak_user_id,
service_id=service.id,
quota_gb=quota_gb,
quota_mb=quota_mb,
enabled_at=datetime.utcnow(),
enabled_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(service_setting)
service_count += 1
db.commit()
db.refresh(emp_setting)
# 審計日誌
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="employee_onboard",
resource_type="tenant_emp_settings",
resource_id=f"{tenant_id}-{emp_setting.seq_no}",
details=f"Onboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw}): "
f"{dept_count} departments, {role_count} roles, {service_count} services",
ip_address=request.client.host if request.client else None
)
return {
"message": "Employee onboarded successfully",
"employee": {
"tenant_id": emp_setting.tenant_id,
"seq_no": emp_setting.seq_no,
"tenant_emp_code": emp_setting.tenant_emp_code,
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
"keycloak_username": emp_setting.tenant_keycloak_username,
"name": resume.name_tw,
"hire_date": emp_setting.hire_at.isoformat(),
},
"summary": {
"departments_assigned": dept_count,
"roles_assigned": role_count,
"services_enabled": service_count,
}
}
@router.post("/{tenant_id}/{seq_no}/offboard")
def offboard_employee(
tenant_id: int,
seq_no: int,
request: Request,
db: Session = Depends(get_db),
):
"""
員工離職流程
執行項目:
1. 軟刪除所有部門歸屬
2. 撤銷所有使用者角色
3. 停用所有個人化服務
4. 設定員工狀態為 resigned
"""
current_tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
# 檢查租戶權限
if tenant_id != current_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No permission to access this tenant"
)
# 查詢員工任用設定
emp_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.seq_no == seq_no
).first()
if not emp_setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
)
if emp_setting.employment_status == "resigned":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Employee already resigned"
)
keycloak_user_id = emp_setting.tenant_keycloak_user_id
resume_id = emp_setting.tenant_resume_id
# Step 1: 軟刪除所有部門歸屬
dept_members = db.query(DepartmentMember).filter(
DepartmentMember.tenant_id == tenant_id,
DepartmentMember.employee_id == resume_id,
DepartmentMember.is_active == True
).all()
for dm in dept_members:
dm.is_active = False
dm.ended_at = datetime.utcnow()
dm.removed_by = current_user
dm.edit_by = current_user
# Step 2: 撤銷所有使用者角色
role_assignments = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.is_active == True
).all()
for ra in role_assignments:
ra.is_active = False
ra.revoked_at = datetime.utcnow()
ra.revoked_by = current_user
ra.edit_by = current_user
# Step 3: 停用所有個人化服務
service_settings = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.is_active == True
).all()
for ss in service_settings:
ss.is_active = False
ss.disabled_at = datetime.utcnow()
ss.disabled_by = current_user
ss.edit_by = current_user
# Step 4: 設定離職日期和狀態
emp_setting.resign_date = date.today()
emp_setting.employment_status = "resigned"
emp_setting.is_active = False
emp_setting.edit_by = current_user
db.commit()
# 審計日誌
resume = emp_setting.resume
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="employee_offboard",
resource_type="tenant_emp_settings",
resource_id=f"{tenant_id}-{seq_no}",
details=f"Offboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw if resume else 'Unknown'}): "
f"{len(dept_members)} departments removed, {len(role_assignments)} roles revoked, "
f"{len(service_settings)} services disabled",
ip_address=request.client.host if request.client else None
)
return {
"message": "Employee offboarded successfully",
"employee": {
"tenant_emp_code": emp_setting.tenant_emp_code,
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
},
"summary": {
"departments_removed": len(dept_members),
"roles_revoked": len(role_assignments),
"services_disabled": len(service_settings),
}
}
@router.get("/{tenant_id}/{seq_no}/status")
def get_employee_onboarding_status(
tenant_id: int,
seq_no: int,
db: Session = Depends(get_db),
):
"""
查詢員工完整的到職狀態
回傳:
- 員工基本資訊
- 部門歸屬列表
- 角色分配列表
- 個人化服務列表
"""
current_tenant_id = get_current_tenant_id()
if tenant_id != current_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No permission to access this tenant"
)
emp_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.seq_no == seq_no
).first()
if not emp_setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
)
resume = emp_setting.resume
keycloak_user_id = emp_setting.tenant_keycloak_user_id
# 查詢部門歸屬
dept_members = db.query(DepartmentMember).filter(
DepartmentMember.tenant_id == tenant_id,
DepartmentMember.employee_id == emp_setting.tenant_resume_id,
DepartmentMember.is_active == True
).all()
departments = [
{
"department_id": dm.department_id,
"department_name": dm.department.name if dm.department else None,
"position": dm.position,
"membership_type": dm.membership_type,
"joined_at": dm.joined_at.isoformat() if dm.joined_at else None,
}
for dm in dept_members
]
# 查詢角色分配
role_assignments = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.is_active == True
).all()
roles = [
{
"role_id": ra.role_id,
"role_name": ra.role.role_name if ra.role else None,
"role_code": ra.role.role_code if ra.role else None,
"assigned_at": ra.assigned_at.isoformat() if ra.assigned_at else None,
}
for ra in role_assignments
]
# 查詢個人化服務
service_settings = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.is_active == True
).all()
services = [
{
"service_id": ss.service_id,
"service_name": ss.service.service_name if ss.service else None,
"service_code": ss.service.service_code if ss.service else None,
"quota_gb": ss.quota_gb,
"quota_mb": ss.quota_mb,
"enabled_at": ss.enabled_at.isoformat() if ss.enabled_at else None,
}
for ss in service_settings
]
return {
"employee": {
"tenant_id": emp_setting.tenant_id,
"seq_no": emp_setting.seq_no,
"tenant_emp_code": emp_setting.tenant_emp_code,
"name": resume.name_tw if resume else None,
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
"keycloak_username": emp_setting.tenant_keycloak_username,
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
"employment_status": emp_setting.employment_status,
"storage_quota_gb": emp_setting.storage_quota_gb,
"email_quota_mb": emp_setting.email_quota_mb,
},
"departments": departments,
"roles": roles,
"services": services,
}