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,445 @@
"""
郵件帳號管理 API
符合 WebMail 設計規範 - 員工只能使用 HR Portal 授權的郵件帳號
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_
from app.db.session import get_db
from app.models.employee import Employee
from app.models.email_account import EmailAccount
from app.schemas.email_account import (
EmailAccountCreate,
EmailAccountUpdate,
EmailAccountResponse,
EmailAccountListItem,
EmailAccountQuotaUpdate,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.schemas.response import SuccessResponse, MessageResponse
from app.api.deps import get_pagination_params
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
return 1
@router.get("/", response_model=PaginatedResponse)
def get_email_accounts(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
is_active: Optional[bool] = Query(None, description="狀態篩選"),
search: Optional[str] = Query(None, description="搜尋郵件地址"),
):
"""
獲取郵件帳號列表
支援:
- 分頁
- 員工篩選
- 狀態篩選
- 郵件地址搜尋
"""
tenant_id = get_current_tenant_id()
query = db.query(EmailAccount).filter(EmailAccount.tenant_id == tenant_id)
# 員工篩選
if employee_id:
query = query.filter(EmailAccount.employee_id == employee_id)
# 狀態篩選
if is_active is not None:
query = query.filter(EmailAccount.is_active == is_active)
# 搜尋
if search:
search_pattern = f"%{search}%"
query = query.filter(EmailAccount.email_address.ilike(search_pattern))
# 總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
email_accounts = (
query.options(joinedload(EmailAccount.employee))
.offset(offset)
.limit(pagination.page_size)
.all()
)
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
# 組裝回應資料
items = []
for account in email_accounts:
item = EmailAccountListItem.model_validate(account)
item.employee_name = account.employee.legal_name if account.employee else None
item.employee_number = account.employee.employee_id if account.employee else None
items.append(item)
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=items,
)
@router.get("/{email_account_id}", response_model=EmailAccountResponse)
def get_email_account(
email_account_id: int,
db: Session = Depends(get_db),
):
"""
獲取郵件帳號詳情
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.post("/", response_model=EmailAccountResponse, status_code=status.HTTP_201_CREATED)
def create_email_account(
account_data: EmailAccountCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
創建郵件帳號
注意:
- 郵件地址必須唯一
- 員工必須存在
- 配額範圍: 1GB - 100GB
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == account_data.employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {account_data.employee_id} not found",
)
# 檢查郵件地址是否已存在
existing = db.query(EmailAccount).filter(
EmailAccount.email_address == account_data.email_address
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{account_data.email_address}' already exists",
)
# 創建郵件帳號
account = EmailAccount(
tenant_id=tenant_id,
employee_id=account_data.employee_id,
email_address=account_data.email_address,
quota_mb=account_data.quota_mb,
forward_to=account_data.forward_to,
auto_reply=account_data.auto_reply,
is_active=account_data.is_active,
)
db.add(account)
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="create_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"employee_id": account.employee_id,
"quota_mb": account.quota_mb,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
return response
@router.put("/{email_account_id}", response_model=EmailAccountResponse)
def update_email_account(
email_account_id: int,
account_data: EmailAccountUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新郵件帳號
可更新:
- 配額
- 轉寄地址
- 自動回覆
- 啟用狀態
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 記錄變更前的值
changes = {}
# 更新欄位
update_data = account_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
old_value = getattr(account, field)
if old_value != value:
changes[field] = {"from": old_value, "to": value}
setattr(account, field, value)
if changes:
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"changes": changes,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.patch("/{email_account_id}/quota", response_model=EmailAccountResponse)
def update_email_quota(
email_account_id: int,
quota_data: EmailAccountQuotaUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新郵件配額
快速更新配額的端點
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
old_quota = account.quota_mb
account.quota_mb = quota_data.quota_mb
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_email_quota",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"old_quota_mb": old_quota,
"new_quota_mb": quota_data.quota_mb,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.delete("/{email_account_id}", response_model=MessageResponse)
def delete_email_account(
email_account_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
刪除郵件帳號
注意:
- 軟刪除: 設為停用
- 需要記錄審計日誌
"""
tenant_id = get_current_tenant_id()
account = db.query(EmailAccount).filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
).first()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 軟刪除: 設為停用
account.is_active = False
db.commit()
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="delete_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"employee_id": account.employee_id,
},
)
return MessageResponse(
message=f"Email account {account.email_address} has been deactivated"
)
@router.get("/employees/{employee_id}/email-accounts")
def get_employee_email_accounts(
employee_id: int,
db: Session = Depends(get_db),
):
"""
取得員工授權的郵件帳號列表
符合 WebMail 設計規範 (HR Portal設計文件 §2):
- 員工不可自行新增郵件帳號
- 只能使用 HR Portal 授予的帳號
- 支援多帳號切換 (ISO 帳號管理流程)
回傳格式:
{
"user_id": "porsche.chen",
"email_accounts": [
{
"email": "porsche.chen@porscheworld.tw",
"quota_mb": 5120,
"status": "active",
...
}
]
}
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found",
)
# 查詢員工的所有啟用郵件帳號
accounts = (
db.query(EmailAccount)
.filter(
EmailAccount.employee_id == employee_id,
EmailAccount.tenant_id == tenant_id,
EmailAccount.is_active == True,
)
.all()
)
# 組裝符合 WebMail 設計規範的回應格式
email_accounts = []
for account in accounts:
email_accounts.append({
"email": account.email_address,
"quota_mb": account.quota_mb,
"status": "active" if account.is_active else "inactive",
"forward_to": account.forward_to,
"auto_reply": account.auto_reply,
})
return {
"user_id": employee.username_base,
"email_accounts": email_accounts,
}