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:
326
backend/app/api/v1/personal_service_settings.py
Normal file
326
backend/app/api/v1/personal_service_settings.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
個人化服務設定 API
|
||||
記錄員工啟用的個人化服務(SSO, Email, Calendar, Drive, Office)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
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"
|
||||
|
||||
|
||||
@router.get("/users/{keycloak_user_id}/services")
|
||||
def get_user_services(
|
||||
keycloak_user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得使用者已啟用的服務列表
|
||||
|
||||
Args:
|
||||
keycloak_user_id: Keycloak User UUID
|
||||
include_inactive: 是否包含已停用的服務
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
query = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id
|
||||
)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(
|
||||
EmpPersonalServiceSetting.is_active == True,
|
||||
EmpPersonalServiceSetting.disabled_at == None
|
||||
)
|
||||
|
||||
settings = query.all()
|
||||
|
||||
result = []
|
||||
for setting in settings:
|
||||
service = setting.service
|
||||
result.append({
|
||||
"id": setting.id,
|
||||
"service_id": setting.service_id,
|
||||
"service_name": service.service_name if service else None,
|
||||
"service_code": service.service_code if service else None,
|
||||
"quota_gb": setting.quota_gb,
|
||||
"quota_mb": setting.quota_mb,
|
||||
"enabled_at": setting.enabled_at,
|
||||
"enabled_by": setting.enabled_by,
|
||||
"disabled_at": setting.disabled_at,
|
||||
"disabled_by": setting.disabled_by,
|
||||
"is_active": setting.is_active,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/users/{keycloak_user_id}/services", status_code=status.HTTP_201_CREATED)
|
||||
def enable_service_for_user(
|
||||
keycloak_user_id: str,
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
為使用者啟用個人化服務
|
||||
|
||||
Body:
|
||||
{
|
||||
"service_id": 4, // 服務 ID (必填)
|
||||
"quota_gb": 20, // 儲存配額 (Drive 服務用)
|
||||
"quota_mb": 5120 // 郵件配額 (Email 服務用)
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
service_id = data.get("service_id")
|
||||
quota_gb = data.get("quota_gb")
|
||||
quota_mb = data.get("quota_mb")
|
||||
|
||||
if not service_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="service_id is required"
|
||||
)
|
||||
|
||||
# 檢查服務是否存在
|
||||
service = db.query(PersonalService).filter(
|
||||
PersonalService.id == service_id,
|
||||
PersonalService.is_active == True
|
||||
).first()
|
||||
|
||||
if not service:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service with id {service_id} not found or inactive"
|
||||
)
|
||||
|
||||
# 檢查是否已經啟用
|
||||
existing = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Service {service.service_name} already enabled for this user"
|
||||
)
|
||||
|
||||
# 建立服務設定
|
||||
setting = EmpPersonalServiceSetting(
|
||||
tenant_id=tenant_id,
|
||||
tenant_keycloak_user_id=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(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
# 審計日誌
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="enable_service",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=setting.id,
|
||||
details=f"Enabled {service.service_name} for user {keycloak_user_id}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"id": setting.id,
|
||||
"service_id": setting.service_id,
|
||||
"service_name": service.service_name,
|
||||
"enabled_at": setting.enabled_at,
|
||||
"quota_gb": setting.quota_gb,
|
||||
"quota_mb": setting.quota_mb,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/users/{keycloak_user_id}/services/{service_id}")
|
||||
def disable_service_for_user(
|
||||
keycloak_user_id: str,
|
||||
service_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
停用使用者的個人化服務(軟刪除)
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
# 查詢啟用中的服務設定
|
||||
setting = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Service setting not found or already disabled"
|
||||
)
|
||||
|
||||
# 軟刪除
|
||||
setting.is_active = False
|
||||
setting.disabled_at = datetime.utcnow()
|
||||
setting.disabled_by = current_user
|
||||
setting.edit_by = current_user
|
||||
|
||||
db.commit()
|
||||
|
||||
# 審計日誌
|
||||
service = setting.service
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="disable_service",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=setting.id,
|
||||
details=f"Disabled {service.service_name if service else service_id} for user {keycloak_user_id}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return MessageResponse(message="Service disabled successfully")
|
||||
|
||||
|
||||
@router.post("/users/{keycloak_user_id}/services/batch-enable", status_code=status.HTTP_201_CREATED)
|
||||
def batch_enable_services(
|
||||
keycloak_user_id: str,
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批次啟用所有個人化服務(員工到職時使用)
|
||||
|
||||
Body:
|
||||
{
|
||||
"storage_quota_gb": 20,
|
||||
"email_quota_mb": 5120
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
storage_quota_gb = data.get("storage_quota_gb", 20)
|
||||
email_quota_mb = data.get("email_quota_mb", 5120)
|
||||
|
||||
# 取得所有啟用的服務
|
||||
all_services = db.query(PersonalService).filter(
|
||||
PersonalService.is_active == True
|
||||
).all()
|
||||
|
||||
enabled_services = []
|
||||
|
||||
for service in all_services:
|
||||
# 檢查是否已啟用
|
||||
existing = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service.id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
continue # 已啟用,跳過
|
||||
|
||||
# 根據服務類型設定配額
|
||||
quota_gb = storage_quota_gb if service.service_code == "Drive" else None
|
||||
quota_mb = email_quota_mb if service.service_code == "Email" else None
|
||||
|
||||
setting = EmpPersonalServiceSetting(
|
||||
tenant_id=tenant_id,
|
||||
tenant_keycloak_user_id=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(setting)
|
||||
enabled_services.append(service.service_name)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 審計日誌
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="batch_enable_services",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=None,
|
||||
details=f"Batch enabled {len(enabled_services)} services for user {keycloak_user_id}: {', '.join(enabled_services)}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled_count": len(enabled_services),
|
||||
"services": enabled_services
|
||||
}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
def get_all_services(
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得所有可用的個人化服務列表
|
||||
"""
|
||||
query = db.query(PersonalService)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(PersonalService.is_active == True)
|
||||
|
||||
services = query.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"service_name": s.service_name,
|
||||
"service_code": s.service_code,
|
||||
"is_active": s.is_active,
|
||||
}
|
||||
for s in services
|
||||
]
|
||||
Reference in New Issue
Block a user