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,209 @@
"""
審計日誌 API
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.audit_log import AuditLog
from app.schemas.audit_log import (
AuditLogResponse,
AuditLogListItem,
AuditLogFilter,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.api.deps import get_pagination_params
router = APIRouter()
@router.get("/", response_model=PaginatedResponse)
def get_audit_logs(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
action: Optional[str] = Query(None, description="操作類型篩選"),
resource_type: Optional[str] = Query(None, description="資源類型篩選"),
resource_id: Optional[int] = Query(None, description="資源 ID 篩選"),
performed_by: Optional[str] = Query(None, description="操作者篩選"),
start_date: Optional[datetime] = Query(None, description="開始日期"),
end_date: Optional[datetime] = Query(None, description="結束日期"),
):
"""
獲取審計日誌列表
支援:
- 分頁
- 多種篩選條件
- 時間範圍篩選
"""
query = db.query(AuditLog)
# 操作類型篩選
if action:
query = query.filter(AuditLog.action == action)
# 資源類型篩選
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
# 資源 ID 篩選
if resource_id is not None:
query = query.filter(AuditLog.resource_id == resource_id)
# 操作者篩選
if performed_by:
query = query.filter(AuditLog.performed_by.ilike(f"%{performed_by}%"))
# 時間範圍篩選
if start_date:
query = query.filter(AuditLog.performed_at >= start_date)
if end_date:
query = query.filter(AuditLog.performed_at <= end_date)
# 總數
total = query.count()
# 分頁 (按時間倒序)
offset = (pagination.page - 1) * pagination.page_size
audit_logs = query.order_by(
AuditLog.performed_at.desc()
).offset(offset).limit(pagination.page_size).all()
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=[AuditLogListItem.model_validate(log) for log in audit_logs],
)
@router.get("/{audit_log_id}", response_model=AuditLogResponse)
def get_audit_log(
audit_log_id: int,
db: Session = Depends(get_db),
):
"""
獲取審計日誌詳情
"""
audit_log = db.query(AuditLog).filter(
AuditLog.id == audit_log_id
).first()
if not audit_log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Audit log with id {audit_log_id} not found"
)
return AuditLogResponse.model_validate(audit_log)
@router.get("/resource/{resource_type}/{resource_id}", response_model=List[AuditLogListItem])
def get_resource_audit_logs(
resource_type: str,
resource_id: int,
db: Session = Depends(get_db),
):
"""
獲取特定資源的所有審計日誌
Args:
resource_type: 資源類型 (employee, identity, department, etc.)
resource_id: 資源 ID
Returns:
該資源的所有操作記錄 (按時間倒序)
"""
audit_logs = db.query(AuditLog).filter(
AuditLog.resource_type == resource_type,
AuditLog.resource_id == resource_id
).order_by(AuditLog.performed_at.desc()).all()
return [AuditLogListItem.model_validate(log) for log in audit_logs]
@router.get("/user/{username}", response_model=List[AuditLogListItem])
def get_user_audit_logs(
username: str,
db: Session = Depends(get_db),
limit: int = Query(100, le=1000, description="限制返回數量"),
):
"""
獲取特定用戶的操作記錄
Args:
username: 操作者 SSO 帳號
limit: 限制返回數量 (預設 100,最大 1000)
Returns:
該用戶的操作記錄 (按時間倒序)
"""
audit_logs = db.query(AuditLog).filter(
AuditLog.performed_by == username
).order_by(AuditLog.performed_at.desc()).limit(limit).all()
return [AuditLogListItem.model_validate(log) for log in audit_logs]
@router.get("/stats/summary")
def get_audit_stats(
db: Session = Depends(get_db),
start_date: Optional[datetime] = Query(None, description="開始日期"),
end_date: Optional[datetime] = Query(None, description="結束日期"),
):
"""
獲取審計日誌統計
返回:
- 按操作類型分組的統計
- 按資源類型分組的統計
- 操作頻率最高的用戶
"""
query = db.query(AuditLog)
if start_date:
query = query.filter(AuditLog.performed_at >= start_date)
if end_date:
query = query.filter(AuditLog.performed_at <= end_date)
# 總操作數
total_operations = query.count()
# 按操作類型統計
from sqlalchemy import func
action_stats = db.query(
AuditLog.action,
func.count(AuditLog.id).label('count')
).group_by(AuditLog.action).all()
# 按資源類型統計
resource_stats = db.query(
AuditLog.resource_type,
func.count(AuditLog.id).label('count')
).group_by(AuditLog.resource_type).all()
# 操作最多的用戶 (Top 10)
top_users = db.query(
AuditLog.performed_by,
func.count(AuditLog.id).label('count')
).group_by(AuditLog.performed_by).order_by(
func.count(AuditLog.id).desc()
).limit(10).all()
return {
"total_operations": total_operations,
"by_action": {action: count for action, count in action_stats},
"by_resource_type": {resource: count for resource, count in resource_stats},
"top_users": [
{"username": user, "operations": count}
for user, count in top_users
]
}