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,213 @@
"""
事業部管理 API
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.business_unit import BusinessUnit
from app.schemas.business_unit import (
BusinessUnitCreate,
BusinessUnitUpdate,
BusinessUnitResponse,
BusinessUnitListItem,
)
from app.schemas.department import DepartmentListItem
from app.schemas.response import MessageResponse
router = APIRouter()
@router.get("/", response_model=List[BusinessUnitListItem])
def get_business_units(
db: Session = Depends(get_db),
include_inactive: bool = False,
):
"""
獲取事業部列表
Args:
include_inactive: 是否包含停用的事業部
"""
query = db.query(BusinessUnit)
if not include_inactive:
query = query.filter(BusinessUnit.is_active == True)
business_units = query.order_by(BusinessUnit.id).all()
return [BusinessUnitListItem.model_validate(bu) for bu in business_units]
@router.get("/{business_unit_id}", response_model=BusinessUnitResponse)
def get_business_unit(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
獲取事業部詳情
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = len(business_unit.departments)
response.employees_count = business_unit.employee_identities.count()
return response
@router.post("/", response_model=BusinessUnitResponse, status_code=status.HTTP_201_CREATED)
def create_business_unit(
business_unit_data: BusinessUnitCreate,
db: Session = Depends(get_db),
):
"""
創建事業部
檢查:
- code 唯一性
- email_domain 唯一性
"""
# 檢查 code 是否已存在
existing_code = db.query(BusinessUnit).filter(
BusinessUnit.code == business_unit_data.code
).first()
if existing_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Business unit code '{business_unit_data.code}' already exists"
)
# 檢查 email_domain 是否已存在
existing_domain = db.query(BusinessUnit).filter(
BusinessUnit.email_domain == business_unit_data.email_domain
).first()
if existing_domain:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email domain '{business_unit_data.email_domain}' already exists"
)
# 創建事業部
business_unit = BusinessUnit(**business_unit_data.model_dump())
db.add(business_unit)
db.commit()
db.refresh(business_unit)
# TODO: 創建審計日誌
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = 0
response.employees_count = 0
return response
@router.put("/{business_unit_id}", response_model=BusinessUnitResponse)
def update_business_unit(
business_unit_id: int,
business_unit_data: BusinessUnitUpdate,
db: Session = Depends(get_db),
):
"""
更新事業部
注意: code 和 email_domain 不可修改 (在 Schema 中已限制)
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
# 更新欄位
update_data = business_unit_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(business_unit, field, value)
db.commit()
db.refresh(business_unit)
# TODO: 創建審計日誌
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = len(business_unit.departments)
response.employees_count = business_unit.employee_identities.count()
return response
@router.delete("/{business_unit_id}", response_model=MessageResponse)
def delete_business_unit(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
停用事業部
注意: 這是軟刪除,只將 is_active 設為 False
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
# 檢查是否有活躍的員工
active_employees = business_unit.employee_identities.filter_by(is_active=True).count()
if active_employees > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot deactivate business unit with {active_employees} active employees"
)
business_unit.is_active = False
db.commit()
# TODO: 創建審計日誌
return MessageResponse(
message=f"Business unit '{business_unit.name}' has been deactivated"
)
@router.get("/{business_unit_id}/departments", response_model=List[DepartmentListItem])
def get_business_unit_departments(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
獲取事業部的所有部門
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
return [DepartmentListItem.model_validate(dept) for dept in business_unit.departments]