Files
hr-portal/backend/app/api/v1/personal_service_settings.py
Porsche Chen 360533393f 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>
2026-02-23 20:12:43 +08:00

327 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
個人化服務設定 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
]