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:
445
backend/app/api/v1/email_accounts.py
Normal file
445
backend/app/api/v1/email_accounts.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user