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

222
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,222 @@
"""
API 依賴項
用於依賴注入
"""
from typing import Generator, Optional, Dict, Any
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.base import PaginationParams
from app.services.keycloak_service import keycloak_service
from app.models import Tenant
# OAuth2 Bearer Token Scheme
security = HTTPBearer(auto_error=False)
def get_pagination_params(
page: int = 1,
page_size: int = 20,
) -> PaginationParams:
"""
獲取分頁參數
Args:
page: 頁碼 (從 1 開始)
page_size: 每頁數量
Returns:
PaginationParams: 分頁參數
Raises:
HTTPException: 參數驗證失敗
"""
if page < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Page must be greater than 0"
)
if page_size < 1 or page_size > 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Page size must be between 1 and 100"
)
return PaginationParams(page=page, page_size=page_size)
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[Dict[str, Any]]:
"""
獲取當前登入用戶 (從 JWT Token)
Args:
credentials: HTTP Bearer Token
Returns:
dict: 用戶資訊 (包含 username, email, sub 等)
None: 未提供 Token 或 Token 無效時
Raises:
HTTPException: Token 無效時拋出 401
"""
if not credentials:
return None
token = credentials.credentials
# 驗證 Token
user_info = keycloak_service.get_user_info_from_token(token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
return user_info
def require_auth(
current_user: Optional[Dict[str, Any]] = Depends(get_current_user),
) -> Dict[str, Any]:
"""
要求必須認證
Args:
current_user: 當前用戶資訊
Returns:
dict: 用戶資訊
Raises:
HTTPException: 未認證時拋出 401
"""
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
def check_permission(required_roles: list = None):
"""
檢查用戶權限 (基於角色)
Args:
required_roles: 需要的角色列表 (例如: ["admin", "hr_manager"])
Returns:
function: 權限檢查函數
使用範例:
@router.get("/admin-only", dependencies=[Depends(check_permission(["admin"]))])
"""
if required_roles is None:
required_roles = []
def permission_checker(
current_user: Dict[str, Any] = Depends(require_auth)
) -> Dict[str, Any]:
"""檢查用戶是否有所需權限"""
# TODO: 從 Keycloak Token 解析用戶角色
# 目前暫時允許所有已認證用戶
user_roles = current_user.get("realm_access", {}).get("roles", [])
if required_roles:
has_permission = any(role in user_roles for role in required_roles)
if not has_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Required roles: {', '.join(required_roles)}"
)
return current_user
return permission_checker
def get_current_tenant(
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
) -> Tenant:
"""
獲取當前租戶 (從 JWT Token 的 realm)
多租戶架構:每個租戶對應一個 Keycloak Realm
- JWT Token 來自哪個 Realm就屬於哪個租戶
- 透過 iss (Issuer) 欄位解析 Realm 名稱
- 查詢 tenants 表找到對應租戶
Args:
current_user: 當前用戶資訊 (含 iss)
db: 資料庫 Session
Returns:
Tenant: 租戶物件
Raises:
HTTPException: 租戶不存在或未啟用時拋出 403
範例:
iss: "https://auth.lab.taipei/realms/porscheworld"
→ realm_name: "porscheworld"
→ tenant.keycloak_realm: "porscheworld"
"""
# 從 JWT iss 欄位解析 Realm 名稱
# iss 格式: "https://{domain}/realms/{realm_name}"
iss = current_user.get("iss", "")
if not iss:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token: missing issuer"
)
# 解析 realm_name
try:
realm_name = iss.split("/realms/")[-1]
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token: cannot parse realm"
)
# 查詢租戶
tenant = db.query(Tenant).filter(
Tenant.keycloak_realm == realm_name,
Tenant.is_active == True
).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Tenant not found or inactive: {realm_name}"
)
return tenant
def get_tenant_id(
tenant: Tenant = Depends(get_current_tenant)
) -> int:
"""
獲取當前租戶 ID (簡化版)
用於只需要 tenant_id 的場景
Args:
tenant: 租戶物件
Returns:
int: 租戶 ID
"""
return tenant.id

View File

@@ -0,0 +1,3 @@
"""
API v1 模組
"""

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
]
}

362
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,362 @@
"""
認證 API
處理登入、登出、Token 管理
"""
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
from app.schemas.response import MessageResponse
from app.api.deps import get_current_user, require_auth
from app.services.keycloak_service import keycloak_service
from app.services.audit_service import audit_service
router = APIRouter()
@router.post("/login", response_model=TokenResponse)
def login(
login_data: LoginRequest,
request: Request,
db: Session = Depends(get_db),
):
"""
用戶登入
使用 Keycloak Direct Access Grant (Resource Owner Password Credentials)
獲取 Access Token 和 Refresh Token
Args:
login_data: 登入憑證 (username, password)
request: HTTP Request (用於獲取 IP)
db: 資料庫 Session
Returns:
TokenResponse: Access Token 和 Refresh Token
Raises:
HTTPException: 登入失敗時拋出 401
"""
try:
# 使用 Keycloak 進行認證
token_response = keycloak_service.openid.token(
login_data.username,
login_data.password
)
# 記錄登入成功的審計日誌
audit_service.log_login(
db=db,
username=login_data.username,
ip_address=audit_service.get_client_ip(request),
success=True
)
return TokenResponse(
access_token=token_response["access_token"],
token_type=token_response["token_type"],
expires_in=token_response["expires_in"],
refresh_token=token_response.get("refresh_token")
)
except Exception as e:
# 記錄登入失敗的審計日誌
audit_service.log_login(
db=db,
username=login_data.username,
ip_address=audit_service.get_client_ip(request),
success=False
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post("/logout", response_model=MessageResponse)
def logout(
request: Request,
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
):
"""
用戶登出
記錄登出審計日誌
Args:
request: HTTP Request
current_user: 當前用戶資訊
db: 資料庫 Session
Returns:
MessageResponse: 登出成功訊息
"""
# 記錄登出審計日誌
audit_service.log_logout(
db=db,
username=current_user["username"],
ip_address=audit_service.get_client_ip(request)
)
# TODO: 可選擇在 Keycloak 端撤銷 Token
# keycloak_service.openid.logout(refresh_token)
return MessageResponse(
message=f"User {current_user['username']} logged out successfully"
)
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(
refresh_token: str,
):
"""
刷新 Access Token
使用 Refresh Token 獲取新的 Access Token
Args:
refresh_token: Refresh Token
Returns:
TokenResponse: 新的 Access Token 和 Refresh Token
Raises:
HTTPException: Refresh Token 無效時拋出 401
"""
try:
# 使用 Refresh Token 獲取新的 Access Token
token_response = keycloak_service.openid.refresh_token(refresh_token)
return TokenResponse(
access_token=token_response["access_token"],
token_type=token_response["token_type"],
expires_in=token_response["expires_in"],
refresh_token=token_response.get("refresh_token")
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.get("/me", response_model=UserInfo)
def get_current_user_info(
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db)
):
"""
獲取當前用戶資訊
從 JWT Token 解析用戶資訊,並查詢租戶資訊
Args:
current_user: 當前用戶資訊 (從 Token 解析)
db: 資料庫 Session
Returns:
UserInfo: 用戶詳細資訊(包含租戶資訊)
"""
# 查詢用戶所屬租戶
from app.models.tenant import Tenant
from app.models.employee import Employee
tenant_info = None
# 從 email 查詢員工,取得租戶資訊
email = current_user.get("email")
if email:
employee = db.query(Employee).filter(Employee.email == email).first()
if employee and employee.tenant_id:
tenant = db.query(Tenant).filter(Tenant.id == employee.tenant_id).first()
if tenant:
tenant_info = {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"is_sysmana": tenant.is_sysmana
}
return UserInfo(
sub=current_user.get("sub", ""),
username=current_user.get("username", ""),
email=current_user.get("email", ""),
first_name=current_user.get("first_name"),
last_name=current_user.get("last_name"),
email_verified=current_user.get("email_verified", False),
tenant=tenant_info
)
@router.post("/change-password", response_model=MessageResponse)
def change_password(
old_password: str,
new_password: str,
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
request: Request = None,
):
"""
修改密碼
用戶修改自己的密碼
Args:
old_password: 舊密碼
new_password: 新密碼
current_user: 當前用戶資訊
db: 資料庫 Session
request: HTTP Request
Returns:
MessageResponse: 成功訊息
Raises:
HTTPException: 舊密碼錯誤或修改失敗時拋出錯誤
"""
username = current_user["username"]
try:
# 1. 驗證舊密碼
try:
keycloak_service.openid.token(username, old_password)
except:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Old password is incorrect"
)
# 2. 獲取用戶 Keycloak ID
user = keycloak_service.get_user_by_username(username)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in Keycloak"
)
user_id = user["id"]
# 3. 重設密碼 (非臨時密碼)
success = keycloak_service.reset_password(
user_id=user_id,
new_password=new_password,
temporary=False
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to change password"
)
# 4. 記錄審計日誌
audit_service.log(
db=db,
action="change_password",
resource_type="authentication",
performed_by=username,
details={"success": True},
ip_address=audit_service.get_client_ip(request) if request else None
)
return MessageResponse(
message="Password changed successfully"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to change password: {str(e)}"
)
@router.post("/reset-password/{username}", response_model=MessageResponse)
def reset_user_password(
username: str,
new_password: str,
temporary: bool = True,
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
request: Request = None,
):
"""
重設用戶密碼 (管理員功能)
管理員為其他用戶重設密碼
Args:
username: 目標用戶名稱
new_password: 新密碼
temporary: 是否為臨時密碼 (用戶首次登入需修改)
current_user: 當前用戶資訊 (管理員)
db: 資料庫 Session
request: HTTP Request
Returns:
MessageResponse: 成功訊息
Raises:
HTTPException: 權限不足或重設失敗時拋出錯誤
"""
# TODO: 檢查是否為管理員
# 目前暫時允許所有已認證用戶
try:
# 1. 獲取目標用戶
user = keycloak_service.get_user_by_username(username)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {username} not found"
)
user_id = user["id"]
# 2. 重設密碼
success = keycloak_service.reset_password(
user_id=user_id,
new_password=new_password,
temporary=temporary
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
# 3. 記錄審計日誌
audit_service.log(
db=db,
action="reset_password",
resource_type="authentication",
performed_by=current_user["username"],
details={
"target_user": username,
"temporary": temporary,
"success": True
},
ip_address=audit_service.get_client_ip(request) if request else None
)
return MessageResponse(
message=f"Password reset for user {username}"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset password: {str(e)}"
)

View File

@@ -0,0 +1,213 @@
"""
事業部管理 API
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.business_unit import BusinessUnit
from app.schemas.business_unit import (
BusinessUnitCreate,
BusinessUnitUpdate,
BusinessUnitResponse,
BusinessUnitListItem,
)
from app.schemas.department import DepartmentListItem
from app.schemas.response import MessageResponse
router = APIRouter()
@router.get("/", response_model=List[BusinessUnitListItem])
def get_business_units(
db: Session = Depends(get_db),
include_inactive: bool = False,
):
"""
獲取事業部列表
Args:
include_inactive: 是否包含停用的事業部
"""
query = db.query(BusinessUnit)
if not include_inactive:
query = query.filter(BusinessUnit.is_active == True)
business_units = query.order_by(BusinessUnit.id).all()
return [BusinessUnitListItem.model_validate(bu) for bu in business_units]
@router.get("/{business_unit_id}", response_model=BusinessUnitResponse)
def get_business_unit(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
獲取事業部詳情
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = len(business_unit.departments)
response.employees_count = business_unit.employee_identities.count()
return response
@router.post("/", response_model=BusinessUnitResponse, status_code=status.HTTP_201_CREATED)
def create_business_unit(
business_unit_data: BusinessUnitCreate,
db: Session = Depends(get_db),
):
"""
創建事業部
檢查:
- code 唯一性
- email_domain 唯一性
"""
# 檢查 code 是否已存在
existing_code = db.query(BusinessUnit).filter(
BusinessUnit.code == business_unit_data.code
).first()
if existing_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Business unit code '{business_unit_data.code}' already exists"
)
# 檢查 email_domain 是否已存在
existing_domain = db.query(BusinessUnit).filter(
BusinessUnit.email_domain == business_unit_data.email_domain
).first()
if existing_domain:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email domain '{business_unit_data.email_domain}' already exists"
)
# 創建事業部
business_unit = BusinessUnit(**business_unit_data.model_dump())
db.add(business_unit)
db.commit()
db.refresh(business_unit)
# TODO: 創建審計日誌
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = 0
response.employees_count = 0
return response
@router.put("/{business_unit_id}", response_model=BusinessUnitResponse)
def update_business_unit(
business_unit_id: int,
business_unit_data: BusinessUnitUpdate,
db: Session = Depends(get_db),
):
"""
更新事業部
注意: code 和 email_domain 不可修改 (在 Schema 中已限制)
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
# 更新欄位
update_data = business_unit_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(business_unit, field, value)
db.commit()
db.refresh(business_unit)
# TODO: 創建審計日誌
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = len(business_unit.departments)
response.employees_count = business_unit.employee_identities.count()
return response
@router.delete("/{business_unit_id}", response_model=MessageResponse)
def delete_business_unit(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
停用事業部
注意: 這是軟刪除,只將 is_active 設為 False
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
# 檢查是否有活躍的員工
active_employees = business_unit.employee_identities.filter_by(is_active=True).count()
if active_employees > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot deactivate business unit with {active_employees} active employees"
)
business_unit.is_active = False
db.commit()
# TODO: 創建審計日誌
return MessageResponse(
message=f"Business unit '{business_unit.name}' has been deactivated"
)
@router.get("/{business_unit_id}/departments", response_model=List[DepartmentListItem])
def get_business_unit_departments(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
獲取事業部的所有部門
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
return [DepartmentListItem.model_validate(dept) for dept in business_unit.departments]

View File

@@ -0,0 +1,226 @@
"""
部門成員管理 API
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.department_member import DepartmentMember
from app.models.employee import Employee
from app.models.department import Department
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
@router.get("/")
def get_department_members(
db: Session = Depends(get_db),
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
department_id: Optional[int] = Query(None, description="部門 ID 篩選"),
include_inactive: bool = False,
):
"""
取得部門成員列表
可依員工 ID 或部門 ID 篩選
"""
tenant_id = get_current_tenant_id()
query = db.query(DepartmentMember).filter(DepartmentMember.tenant_id == tenant_id)
if employee_id:
query = query.filter(DepartmentMember.employee_id == employee_id)
if department_id:
query = query.filter(DepartmentMember.department_id == department_id)
if not include_inactive:
query = query.filter(DepartmentMember.is_active == True)
members = query.all()
result = []
for m in members:
dept = m.department
emp = m.employee
result.append({
"id": m.id,
"employee_id": m.employee_id,
"employee_name": emp.legal_name if emp else None,
"employee_number": emp.employee_id if emp else None,
"department_id": m.department_id,
"department_name": dept.name if dept else None,
"department_code": dept.code if dept else None,
"department_depth": dept.depth if dept else None,
"position": m.position,
"membership_type": m.membership_type,
"is_active": m.is_active,
"joined_at": m.joined_at,
"ended_at": m.ended_at,
})
return result
@router.post("/", status_code=status.HTTP_201_CREATED)
def add_employee_to_department(
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
將員工加入部門
Body:
{
"employee_id": 1,
"department_id": 3,
"position": "資深工程師",
"membership_type": "permanent"
}
"""
tenant_id = get_current_tenant_id()
employee_id = data.get("employee_id")
department_id = data.get("department_id")
position = data.get("position")
membership_type = data.get("membership_type", "permanent")
if not employee_id or not department_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="employee_id and department_id are required"
)
# 驗證員工存在
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"
)
# 驗證部門存在
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
# 檢查是否已存在
existing = db.query(DepartmentMember).filter(
DepartmentMember.employee_id == employee_id,
DepartmentMember.department_id == department_id,
).first()
if existing:
if existing.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Employee {employee_id} is already a member of department {department_id}"
)
else:
# 重新啟用
existing.is_active = True
existing.position = position
existing.membership_type = membership_type
existing.ended_at = None
db.commit()
db.refresh(existing)
return {
"id": existing.id,
"employee_id": existing.employee_id,
"department_id": existing.department_id,
"position": existing.position,
"membership_type": existing.membership_type,
"is_active": existing.is_active,
}
member = DepartmentMember(
tenant_id=tenant_id,
employee_id=employee_id,
department_id=department_id,
position=position,
membership_type=membership_type,
)
db.add(member)
db.commit()
db.refresh(member)
audit_service.log_action(
request=request,
db=db,
action="add_department_member",
resource_type="department_member",
resource_id=member.id,
details={
"employee_id": employee_id,
"department_id": department_id,
"position": position,
},
)
return {
"id": member.id,
"employee_id": member.employee_id,
"department_id": member.department_id,
"position": member.position,
"membership_type": member.membership_type,
"is_active": member.is_active,
"joined_at": member.joined_at,
}
@router.delete("/{member_id}", response_model=MessageResponse)
def remove_employee_from_department(
member_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""將員工從部門移除 (軟刪除)"""
tenant_id = get_current_tenant_id()
member = db.query(DepartmentMember).filter(
DepartmentMember.id == member_id,
DepartmentMember.tenant_id == tenant_id,
).first()
if not member:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department member with id {member_id} not found"
)
from datetime import datetime
member.is_active = False
member.ended_at = datetime.utcnow()
db.commit()
audit_service.log_action(
request=request,
db=db,
action="remove_department_member",
resource_type="department_member",
resource_id=member_id,
details={
"employee_id": member.employee_id,
"department_id": member.department_id,
},
)
return MessageResponse(message=f"Employee removed from department successfully")

View File

@@ -0,0 +1,373 @@
"""
部門管理 API (統一樹狀結構)
設計原則:
- depth=0, parent_id=NULL: 第一層部門 (原事業部),可設定 email_domain
- depth>=1: 子部門email_domain 繼承第一層祖先
- 取代舊的 business_units API
"""
from typing import List, Optional, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.api.deps import get_tenant_id, get_current_tenant
from app.models.department import Department
from app.models.department_member import DepartmentMember
from app.schemas.department import (
DepartmentCreate,
DepartmentUpdate,
DepartmentResponse,
DepartmentListItem,
DepartmentTreeNode,
)
from app.schemas.response import MessageResponse
router = APIRouter()
def get_effective_email_domain(department: Department, db: Session) -> str | None:
"""取得部門的有效郵件網域 (第一層自身,子層向上追溯)"""
if department.depth == 0:
return department.email_domain
if department.parent_id:
parent = db.query(Department).filter(Department.id == department.parent_id).first()
if parent:
return get_effective_email_domain(parent, db)
return None
def build_tree(departments: List[Department], parent_id: int | None, db: Session) -> List[Dict]:
"""遞迴建立部門樹狀結構"""
nodes = []
for dept in departments:
if dept.parent_id == parent_id:
children = build_tree(departments, dept.id, db)
node = {
"id": dept.id,
"code": dept.code,
"name": dept.name,
"name_en": dept.name_en,
"depth": dept.depth,
"parent_id": dept.parent_id,
"email_domain": dept.email_domain,
"effective_email_domain": get_effective_email_domain(dept, db),
"email_address": dept.email_address,
"email_quota_mb": dept.email_quota_mb,
"description": dept.description,
"is_active": dept.is_active,
"is_top_level": dept.depth == 0 and dept.parent_id is None,
"member_count": dept.members.filter_by(is_active=True).count(),
"children": children,
}
nodes.append(node)
return nodes
@router.get("/tree")
def get_departments_tree(
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
include_inactive: bool = False,
):
"""
取得完整部門樹狀結構
回傳格式:
[
{
"id": 1, "code": "BD", "name": "業務發展部", "depth": 0,
"email_domain": "ease.taipei",
"children": [
{"id": 4, "code": "WIND", "name": "玄鐵風能部", "depth": 1, ...}
]
},
...
]
"""
query = db.query(Department).filter(Department.tenant_id == tenant_id)
if not include_inactive:
query = query.filter(Department.is_active == True)
all_departments = query.order_by(Department.depth, Department.id).all()
tree = build_tree(all_departments, None, db)
return tree
@router.get("/", response_model=List[DepartmentListItem])
def get_departments(
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
parent_id: Optional[int] = Query(None, description="上層部門 ID 篩選 (0=取得第一層)"),
depth: Optional[int] = Query(None, description="層次深度篩選 (0=第一層1=第二層)"),
include_inactive: bool = False,
):
"""
獲取部門列表
Args:
parent_id: 上層部門 ID 篩選
depth: 層次深度篩選 (0=第一層即原事業部1=第二層子部門)
include_inactive: 是否包含停用的部門
"""
query = db.query(Department).filter(Department.tenant_id == tenant_id)
if depth is not None:
query = query.filter(Department.depth == depth)
if parent_id is not None:
if parent_id == 0:
query = query.filter(Department.parent_id == None)
else:
query = query.filter(Department.parent_id == parent_id)
if not include_inactive:
query = query.filter(Department.is_active == True)
departments = query.order_by(Department.depth, Department.id).all()
result = []
for dept in departments:
item = DepartmentListItem.model_validate(dept)
item.effective_email_domain = get_effective_email_domain(dept, db)
item.member_count = dept.members.filter_by(is_active=True).count()
result.append(item)
return result
@router.get("/{department_id}", response_model=DepartmentResponse)
def get_department(
department_id: int,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""取得部門詳情"""
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
response = DepartmentResponse.model_validate(department)
response.effective_email_domain = get_effective_email_domain(department, db)
response.member_count = department.members.filter_by(is_active=True).count()
if department.parent:
response.parent_name = department.parent.name
return response
@router.post("/", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED)
def create_department(
department_data: DepartmentCreate,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
創建部門
規則:
- parent_id=NULL: 建立第一層部門 (depth=0),可設定 email_domain
- parent_id=有值: 建立子部門 (depth=parent.depth+1),不可設定 email_domain (繼承)
"""
depth = 0
parent = None
if department_data.parent_id:
# 檢查上層部門是否存在
parent = db.query(Department).filter(
Department.id == department_data.parent_id,
Department.tenant_id == tenant_id,
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Parent department with id {department_data.parent_id} not found"
)
depth = parent.depth + 1
# 子部門不可設定 email_domain
if hasattr(department_data, 'email_domain') and department_data.email_domain:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="email_domain can only be set on top-level departments (parent_id=NULL)"
)
# 檢查同層內 code 是否已存在
existing = db.query(Department).filter(
Department.tenant_id == tenant_id,
Department.parent_id == department_data.parent_id,
Department.code == department_data.code,
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Department code '{department_data.code}' already exists at this level"
)
data = department_data.model_dump()
data['tenant_id'] = tenant_id
data['depth'] = depth
department = Department(**data)
db.add(department)
db.commit()
db.refresh(department)
response = DepartmentResponse.model_validate(department)
response.effective_email_domain = get_effective_email_domain(department, db)
response.member_count = 0
if parent:
response.parent_name = parent.name
return response
@router.put("/{department_id}", response_model=DepartmentResponse)
def update_department(
department_id: int,
department_data: DepartmentUpdate,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
更新部門
注意: code 和 parent_id 建立後不可修改
第一層部門可更新 email_domain子部門不可更新 email_domain
"""
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
update_data = department_data.model_dump(exclude_unset=True)
# 子部門不可更新 email_domain
if 'email_domain' in update_data and department.depth > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="email_domain can only be set on top-level departments (depth=0)"
)
for field, value in update_data.items():
setattr(department, field, value)
db.commit()
db.refresh(department)
response = DepartmentResponse.model_validate(department)
response.effective_email_domain = get_effective_email_domain(department, db)
response.member_count = department.members.filter_by(is_active=True).count()
if department.parent:
response.parent_name = department.parent.name
return response
@router.delete("/{department_id}", response_model=MessageResponse)
def delete_department(
department_id: int,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
停用部門 (軟刪除)
注意: 有活躍成員的部門不可停用
"""
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
# 檢查是否有活躍的成員
active_members = department.members.filter_by(is_active=True).count()
if active_members > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot deactivate department with {active_members} active members"
)
# 檢查是否有活躍的子部門
active_children = db.query(Department).filter(
Department.parent_id == department_id,
Department.is_active == True,
).count()
if active_children > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot deactivate department with {active_children} active sub-departments"
)
department.is_active = False
db.commit()
return MessageResponse(
message=f"Department '{department.name}' has been deactivated"
)
@router.get("/{department_id}/children")
def get_department_children(
department_id: int,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
include_inactive: bool = False,
):
"""取得部門的直接子部門列表"""
# 確認父部門存在
parent = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
query = db.query(Department).filter(
Department.parent_id == department_id,
Department.tenant_id == tenant_id,
)
if not include_inactive:
query = query.filter(Department.is_active == True)
children = query.order_by(Department.id).all()
effective_domain = get_effective_email_domain(parent, db)
result = []
for dept in children:
item = DepartmentListItem.model_validate(dept)
item.effective_email_domain = effective_domain
item.member_count = dept.members.filter_by(is_active=True).count()
result.append(item)
return result

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,
}

View File

@@ -0,0 +1,468 @@
"""
員工到職流程 API (v3.1 多租戶架構)
使用關聯表方式管理部門、角色、服務
"""
from typing import List, Optional
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.db.session import get_db
from app.models.emp_resume import EmpResume
from app.models.emp_setting import EmpSetting
from app.models.department_member import DepartmentMember
from app.models.role import UserRoleAssignment
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"
# ==================== Schemas ====================
class DepartmentAssignment(BaseModel):
"""部門分配"""
department_id: int
position: Optional[str] = None
membership_type: str = "permanent" # permanent/temporary/project
class OnboardingRequest(BaseModel):
"""到職請求"""
# 人員基本資料
resume_id: int # 已存在的 tenant_emp_resumes.id
# SSO 帳號資訊
keycloak_user_id: str # Keycloak UUID
keycloak_username: str # 登入帳號
# 任用資訊
hire_date: date
# 部門分配
departments: List[DepartmentAssignment]
# 角色分配
role_ids: List[int]
# 配額設定
storage_quota_gb: int = 20
email_quota_mb: int = 5120
# ==================== API Endpoints ====================
@router.post("/onboard", status_code=status.HTTP_201_CREATED)
def onboard_employee(
data: OnboardingRequest,
request: Request,
db: Session = Depends(get_db),
):
"""
完整員工到職流程
執行項目:
1. 建立員工任用設定 (tenant_emp_settings)
2. 分配部門歸屬 (tenant_dept_members)
3. 分配使用者角色 (tenant_user_role_assignments)
4. 啟用所有個人化服務 (tenant_emp_personal_service_settings)
範例:
{
"resume_id": 1,
"keycloak_user_id": "550e8400-e29b-41d4-a716-446655440000",
"keycloak_username": "wang.ming",
"hire_date": "2026-02-20",
"departments": [
{"department_id": 9, "position": "資深工程師", "membership_type": "permanent"},
{"department_id": 12, "position": "專案經理", "membership_type": "project"}
],
"role_ids": [1, 2],
"storage_quota_gb": 20,
"email_quota_mb": 5120
}
"""
tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
# Step 1: 檢查 resume 是否存在
resume = db.query(EmpResume).filter(
EmpResume.id == data.resume_id,
EmpResume.tenant_id == tenant_id
).first()
if not resume:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Resume ID {data.resume_id} not found"
)
# 檢查是否已有任用設定
existing_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.tenant_resume_id == data.resume_id,
EmpSetting.is_active == True
).first()
if existing_setting:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Employee already onboarded (emp_code: {existing_setting.tenant_emp_code})"
)
# Step 2: 建立員工任用設定 (seq_no 由觸發器自動生成)
emp_setting = EmpSetting(
tenant_id=tenant_id,
# seq_no 會由觸發器自動生成
tenant_resume_id=data.resume_id,
# tenant_emp_code 會由觸發器自動生成
tenant_keycloak_user_id=data.keycloak_user_id,
tenant_keycloak_username=data.keycloak_username,
hire_at=data.hire_date,
storage_quota_gb=data.storage_quota_gb,
email_quota_mb=data.email_quota_mb,
employment_status="active",
is_active=True,
edit_by=current_user
)
db.add(emp_setting)
db.flush() # 取得自動生成的 seq_no 和 tenant_emp_code
# Step 3: 分配部門歸屬
dept_count = 0
for dept_assignment in data.departments:
dept_member = DepartmentMember(
tenant_id=tenant_id,
employee_id=data.resume_id, # 使用 resume_id 作為 employee_id
department_id=dept_assignment.department_id,
position=dept_assignment.position,
membership_type=dept_assignment.membership_type,
joined_at=datetime.utcnow(),
assigned_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(dept_member)
dept_count += 1
# Step 4: 分配使用者角色
role_count = 0
for role_id in data.role_ids:
role_assignment = UserRoleAssignment(
tenant_id=tenant_id,
keycloak_user_id=data.keycloak_user_id,
role_id=role_id,
assigned_at=datetime.utcnow(),
assigned_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(role_assignment)
role_count += 1
# Step 5: 啟用所有個人化服務
all_services = db.query(PersonalService).filter(
PersonalService.is_active == True
).all()
service_count = 0
for service in all_services:
# 根據服務類型設定配額
quota_gb = data.storage_quota_gb if service.service_code == "Drive" else None
quota_mb = data.email_quota_mb if service.service_code == "Email" else None
service_setting = EmpPersonalServiceSetting(
tenant_id=tenant_id,
tenant_keycloak_user_id=data.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(service_setting)
service_count += 1
db.commit()
db.refresh(emp_setting)
# 審計日誌
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="employee_onboard",
resource_type="tenant_emp_settings",
resource_id=f"{tenant_id}-{emp_setting.seq_no}",
details=f"Onboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw}): "
f"{dept_count} departments, {role_count} roles, {service_count} services",
ip_address=request.client.host if request.client else None
)
return {
"message": "Employee onboarded successfully",
"employee": {
"tenant_id": emp_setting.tenant_id,
"seq_no": emp_setting.seq_no,
"tenant_emp_code": emp_setting.tenant_emp_code,
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
"keycloak_username": emp_setting.tenant_keycloak_username,
"name": resume.name_tw,
"hire_date": emp_setting.hire_at.isoformat(),
},
"summary": {
"departments_assigned": dept_count,
"roles_assigned": role_count,
"services_enabled": service_count,
}
}
@router.post("/{tenant_id}/{seq_no}/offboard")
def offboard_employee(
tenant_id: int,
seq_no: int,
request: Request,
db: Session = Depends(get_db),
):
"""
員工離職流程
執行項目:
1. 軟刪除所有部門歸屬
2. 撤銷所有使用者角色
3. 停用所有個人化服務
4. 設定員工狀態為 resigned
"""
current_tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
# 檢查租戶權限
if tenant_id != current_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No permission to access this tenant"
)
# 查詢員工任用設定
emp_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.seq_no == seq_no
).first()
if not emp_setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
)
if emp_setting.employment_status == "resigned":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Employee already resigned"
)
keycloak_user_id = emp_setting.tenant_keycloak_user_id
resume_id = emp_setting.tenant_resume_id
# Step 1: 軟刪除所有部門歸屬
dept_members = db.query(DepartmentMember).filter(
DepartmentMember.tenant_id == tenant_id,
DepartmentMember.employee_id == resume_id,
DepartmentMember.is_active == True
).all()
for dm in dept_members:
dm.is_active = False
dm.ended_at = datetime.utcnow()
dm.removed_by = current_user
dm.edit_by = current_user
# Step 2: 撤銷所有使用者角色
role_assignments = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.is_active == True
).all()
for ra in role_assignments:
ra.is_active = False
ra.revoked_at = datetime.utcnow()
ra.revoked_by = current_user
ra.edit_by = current_user
# Step 3: 停用所有個人化服務
service_settings = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.is_active == True
).all()
for ss in service_settings:
ss.is_active = False
ss.disabled_at = datetime.utcnow()
ss.disabled_by = current_user
ss.edit_by = current_user
# Step 4: 設定離職日期和狀態
emp_setting.resign_date = date.today()
emp_setting.employment_status = "resigned"
emp_setting.is_active = False
emp_setting.edit_by = current_user
db.commit()
# 審計日誌
resume = emp_setting.resume
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="employee_offboard",
resource_type="tenant_emp_settings",
resource_id=f"{tenant_id}-{seq_no}",
details=f"Offboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw if resume else 'Unknown'}): "
f"{len(dept_members)} departments removed, {len(role_assignments)} roles revoked, "
f"{len(service_settings)} services disabled",
ip_address=request.client.host if request.client else None
)
return {
"message": "Employee offboarded successfully",
"employee": {
"tenant_emp_code": emp_setting.tenant_emp_code,
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
},
"summary": {
"departments_removed": len(dept_members),
"roles_revoked": len(role_assignments),
"services_disabled": len(service_settings),
}
}
@router.get("/{tenant_id}/{seq_no}/status")
def get_employee_onboarding_status(
tenant_id: int,
seq_no: int,
db: Session = Depends(get_db),
):
"""
查詢員工完整的到職狀態
回傳:
- 員工基本資訊
- 部門歸屬列表
- 角色分配列表
- 個人化服務列表
"""
current_tenant_id = get_current_tenant_id()
if tenant_id != current_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No permission to access this tenant"
)
emp_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.seq_no == seq_no
).first()
if not emp_setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
)
resume = emp_setting.resume
keycloak_user_id = emp_setting.tenant_keycloak_user_id
# 查詢部門歸屬
dept_members = db.query(DepartmentMember).filter(
DepartmentMember.tenant_id == tenant_id,
DepartmentMember.employee_id == emp_setting.tenant_resume_id,
DepartmentMember.is_active == True
).all()
departments = [
{
"department_id": dm.department_id,
"department_name": dm.department.name if dm.department else None,
"position": dm.position,
"membership_type": dm.membership_type,
"joined_at": dm.joined_at.isoformat() if dm.joined_at else None,
}
for dm in dept_members
]
# 查詢角色分配
role_assignments = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.is_active == True
).all()
roles = [
{
"role_id": ra.role_id,
"role_name": ra.role.role_name if ra.role else None,
"role_code": ra.role.role_code if ra.role else None,
"assigned_at": ra.assigned_at.isoformat() if ra.assigned_at else None,
}
for ra in role_assignments
]
# 查詢個人化服務
service_settings = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.is_active == True
).all()
services = [
{
"service_id": ss.service_id,
"service_name": ss.service.service_name if ss.service else None,
"service_code": ss.service.service_code if ss.service else None,
"quota_gb": ss.quota_gb,
"quota_mb": ss.quota_mb,
"enabled_at": ss.enabled_at.isoformat() if ss.enabled_at else None,
}
for ss in service_settings
]
return {
"employee": {
"tenant_id": emp_setting.tenant_id,
"seq_no": emp_setting.seq_no,
"tenant_emp_code": emp_setting.tenant_emp_code,
"name": resume.name_tw if resume else None,
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
"keycloak_username": emp_setting.tenant_keycloak_username,
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
"employment_status": emp_setting.employment_status,
"storage_quota_gb": emp_setting.storage_quota_gb,
"email_quota_mb": emp_setting.email_quota_mb,
},
"departments": departments,
"roles": roles,
"services": services,
}

View File

@@ -0,0 +1,381 @@
"""
員工管理 API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session
from sqlalchemy import or_
from app.db.session import get_db
from app.models.employee import Employee, EmployeeStatus
from app.models.emp_setting import EmpSetting
from app.models.emp_resume import EmpResume
from app.models.department import Department
from app.models.department_member import DepartmentMember
from app.schemas.employee import (
EmployeeCreate,
EmployeeUpdate,
EmployeeResponse,
EmployeeListItem,
)
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()
@router.get("/", response_model=PaginatedResponse)
def get_employees(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
status_filter: Optional[EmployeeStatus] = Query(None, description="員工狀態篩選"),
search: Optional[str] = Query(None, description="搜尋關鍵字 (姓名或帳號)"),
):
"""
獲取員工列表
支援:
- 分頁
- 狀態篩選
- 關鍵字搜尋 (姓名、帳號)
"""
# ⚠️ 暫時改為查詢 EmpSetting (因為 Employee model 對應的 tenant_employees 表不存在)
query = db.query(EmpSetting).join(EmpResume, EmpSetting.tenant_resume_id == EmpResume.id)
# 狀態篩選
if status_filter:
query = query.filter(EmpSetting.employment_status == status_filter)
# 搜尋 (在 EmpResume 中搜尋)
if search:
search_pattern = f"%{search}%"
query = query.filter(
or_(
EmpResume.legal_name.ilike(search_pattern),
EmpResume.english_name.ilike(search_pattern),
EmpSetting.tenant_emp_code.ilike(search_pattern),
)
)
# 總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
emp_settings = query.offset(offset).limit(pagination.page_size).all()
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
# 轉換為回應格式 (暫時簡化,不使用 EmployeeListItem)
items = []
for emp_setting in emp_settings:
resume = emp_setting.resume
items.append({
"id": emp_setting.id if hasattr(emp_setting, 'id') else emp_setting.tenant_id * 10000 + emp_setting.seq_no,
"employee_id": emp_setting.tenant_emp_code,
"legal_name": resume.legal_name if resume else "",
"english_name": resume.english_name if resume else "",
"status": emp_setting.employment_status,
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
"is_active": emp_setting.is_active,
})
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=items,
)
@router.get("/{employee_id}", response_model=EmployeeResponse)
def get_employee(
employee_id: int,
db: Session = Depends(get_db),
):
"""
獲取員工詳情 (Phase 2.4: 包含主要身份完整資訊)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 組建回應
response = EmployeeResponse.model_validate(employee)
response.has_network_drive = employee.network_drive is not None
response.department_count = sum(1 for m in employee.department_memberships if m.is_active)
return response
@router.post("/", response_model=EmployeeResponse, status_code=status.HTTP_201_CREATED)
def create_employee(
employee_data: EmployeeCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
創建員工 (Phase 2.3: 同時創建第一個員工身份)
自動生成員工編號 (EMP001, EMP002, ...)
同時創建第一個 employee_identity 記錄 (主要身份)
"""
# 檢查 username_base 是否已存在
existing = db.query(Employee).filter(
Employee.username_base == employee_data.username_base
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Username '{employee_data.username_base}' already exists"
)
# 檢查部門是否存在 (如果有提供)
department = None
if employee_data.department_id:
department = db.query(Department).filter(
Department.id == employee_data.department_id,
Department.is_active == True
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {employee_data.department_id} not found or inactive"
)
# 生成員工編號
last_employee = db.query(Employee).order_by(Employee.id.desc()).first()
if last_employee and last_employee.employee_id.startswith("EMP"):
try:
last_number = int(last_employee.employee_id[3:])
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
employee_id = f"EMP{new_number:03d}"
# 創建員工
# TODO: 從 JWT token 或 session 獲取 tenant_id,目前暫時使用預設值 1
tenant_id = 1 # 預設租戶 ID
employee = Employee(
tenant_id=tenant_id,
employee_id=employee_id,
username_base=employee_data.username_base,
legal_name=employee_data.legal_name,
english_name=employee_data.english_name,
phone=employee_data.phone,
mobile=employee_data.mobile,
hire_date=employee_data.hire_date,
status=EmployeeStatus.ACTIVE,
)
db.add(employee)
db.flush() # 先 flush 以取得 employee.id
# 若有指定部門,建立 department_member 紀錄
if department:
membership = DepartmentMember(
tenant_id=tenant_id,
employee_id=employee.id,
department_id=department.id,
position=employee_data.job_title,
membership_type="permanent",
is_active=True,
)
db.add(membership)
db.commit()
db.refresh(employee)
# 創建審計日誌
audit_service.log_create(
db=db,
resource_type="employee",
resource_id=employee.id,
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
details={
"employee_id": employee.employee_id,
"username_base": employee.username_base,
"legal_name": employee.legal_name,
"department_id": employee_data.department_id,
"job_title": employee_data.job_title,
},
ip_address=audit_service.get_client_ip(request),
)
response = EmployeeResponse.model_validate(employee)
response.has_network_drive = False
response.department_count = 1 if department else 0
return response
@router.put("/{employee_id}", response_model=EmployeeResponse)
def update_employee(
employee_id: int,
employee_data: EmployeeUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新員工資料
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 記錄舊值 (用於審計日誌)
old_values = audit_service.model_to_dict(employee)
# 更新欄位 (只更新提供的欄位)
update_data = employee_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(employee, field, value)
db.commit()
db.refresh(employee)
# 創建審計日誌
if update_data: # 只有實際有更新時才記錄
audit_service.log_update(
db=db,
resource_type="employee",
resource_id=employee.id,
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
old_values={k: old_values[k] for k in update_data.keys() if k in old_values},
new_values=update_data,
ip_address=audit_service.get_client_ip(request),
)
response = EmployeeResponse.model_validate(employee)
response.has_network_drive = employee.network_drive is not None
response.department_count = sum(1 for m in employee.department_memberships if m.is_active)
return response
@router.delete("/{employee_id}", response_model=MessageResponse)
def delete_employee(
employee_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
停用員工
注意: 這是軟刪除,只將狀態設為 terminated
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 軟刪除
employee.status = EmployeeStatus.TERMINATED
# 停用所有部門成員資格
from datetime import datetime
memberships_deactivated = 0
for membership in employee.department_memberships:
if membership.is_active:
membership.is_active = False
membership.ended_at = datetime.utcnow()
memberships_deactivated += 1
# 停用 NAS 帳號
has_nas = employee.network_drive is not None
if employee.network_drive:
employee.network_drive.is_active = False
db.commit()
# 創建審計日誌
audit_service.log_delete(
db=db,
resource_type="employee",
resource_id=employee.id,
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
details={
"employee_id": employee.employee_id,
"username_base": employee.username_base,
"legal_name": employee.legal_name,
"memberships_deactivated": memberships_deactivated,
"nas_deactivated": has_nas,
},
ip_address=audit_service.get_client_ip(request),
)
# TODO: 停用 Keycloak 帳號
return MessageResponse(
message=f"Employee {employee.employee_id} ({employee.legal_name}) has been terminated"
)
@router.post("/{employee_id}/activate", response_model=MessageResponse)
def activate_employee(
employee_id: int,
db: Session = Depends(get_db),
):
"""
重新啟用員工
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
employee.status = EmployeeStatus.ACTIVE
db.commit()
# TODO: 創建審計日誌
return MessageResponse(
message=f"Employee {employee.employee_id} ({employee.legal_name}) has been activated"
)
@router.get("/{employee_id}/identities", response_model=List, deprecated=True)
def get_employee_identities(
employee_id: int,
db: Session = Depends(get_db),
):
"""
[已廢棄] 獲取員工的所有身份
此端點已廢棄,請使用 GET /department-members/?employee_id={id} 取代
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 廢棄端點: 回傳空列表,請改用 /department-members/?employee_id={id}
return []

View File

@@ -0,0 +1,937 @@
"""
初始化系統 API Endpoints
"""
import os
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from app.api import deps
from app.services.environment_checker import EnvironmentChecker
from app.services.installation_service import InstallationService
from app.models import Tenant, InstallationSession
router = APIRouter()
# ==================== Pydantic Schemas ====================
class DatabaseConfig(BaseModel):
"""資料庫連線設定"""
host: str = Field(..., description="主機位址")
port: int = Field(5432, description="Port")
database: str = Field(..., description="資料庫名稱")
user: str = Field(..., description="使用者帳號")
password: str = Field(..., description="密碼")
class RedisConfig(BaseModel):
"""Redis 連線設定"""
host: str = Field(..., description="主機位址")
port: int = Field(6379, description="Port")
password: Optional[str] = Field(None, description="密碼")
db: int = Field(0, description="資料庫編號")
class KeycloakConfig(BaseModel):
"""Keycloak 連線設定"""
url: str = Field(..., description="Keycloak URL")
realm: str = Field(..., description="Realm 名稱")
admin_username: str = Field(..., description="Admin 帳號")
admin_password: str = Field(..., description="Admin 密碼")
class TenantInfoInput(BaseModel):
"""公司資訊輸入"""
company_name: str
company_name_en: Optional[str] = None
tenant_code: str # Keycloak Realm 名稱
tenant_prefix: str # 員工編號前綴
tax_id: Optional[str] = None
tel: Optional[str] = None
add: Optional[str] = None
domain_set: int = 2 # 郵件網域條件1=組織網域2=部門網域
domain: Optional[str] = None # 組織網域domain_set=1 時使用)
class AdminSetupInput(BaseModel):
"""管理員設定輸入"""
admin_legal_name: str
admin_english_name: str
admin_email: str
admin_phone: Optional[str] = None
password_method: str = Field("auto", description="auto 或 manual")
manual_password: Optional[str] = None
class DepartmentSetupInput(BaseModel):
"""部門設定輸入"""
department_code: str
department_name: str
department_name_en: Optional[str] = None
email_domain: str
depth: int = 0
# ==================== Phase 0: 系統狀態檢查 ====================
@router.get("/check-status")
async def check_system_status(db: Session = Depends(deps.get_db)):
"""
檢查系統狀態三階段Initialization/Operational/Transition
Returns:
current_phase: initialization | operational | transition
is_initialized: True/False
next_action: 建議的下一步操作
"""
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig
try:
# 取得系統狀態記錄(應該只有一筆)
system_status = db.query(InstallationSystemStatus).first()
if not system_status:
# 如果沒有記錄,建立一個初始狀態
system_status = InstallationSystemStatus(
current_phase="initialization",
initialization_completed=False,
is_locked=False
)
db.add(system_status)
db.commit()
db.refresh(system_status)
# 檢查環境配置完成度
required_categories = ["redis", "database", "keycloak"]
configured_categories = db.query(InstallationEnvironmentConfig.config_category).filter(
InstallationEnvironmentConfig.is_configured == True
).distinct().all()
configured_categories = [cat[0] for cat in configured_categories]
# 只計算必要類別中已完成的數量
configured_required_count = sum(1 for cat in required_categories if cat in configured_categories)
all_required_configured = all(cat in configured_categories for cat in required_categories)
result = {
"current_phase": system_status.current_phase,
"is_initialized": system_status.initialization_completed and all_required_configured,
"initialization_completed": system_status.initialization_completed,
"configured_count": configured_required_count,
"configured_categories": configured_categories,
"missing_categories": [cat for cat in required_categories if cat not in configured_categories],
"is_locked": system_status.is_locked,
}
# 根據當前階段決定 next_action
if system_status.current_phase == "initialization":
if all_required_configured:
result["next_action"] = "complete_initialization"
result["message"] = "環境配置完成,請繼續完成初始化流程"
else:
result["next_action"] = "continue_setup"
result["message"] = "請繼續設定環境"
elif system_status.current_phase == "operational":
result["next_action"] = "health_check"
result["message"] = "系統運作中,可進行健康檢查"
result["last_health_check_at"] = system_status.last_health_check_at.isoformat() if system_status.last_health_check_at else None
result["health_check_status"] = system_status.health_check_status
elif system_status.current_phase == "transition":
result["next_action"] = "consistency_check"
result["message"] = "系統處於移轉階段,需進行一致性檢查"
result["env_db_consistent"] = system_status.env_db_consistent
result["inconsistencies"] = system_status.inconsistencies
return result
except Exception as e:
# 如果無法連接資料庫或表不存在,視為未初始化
import traceback
return {
"current_phase": "initialization",
"is_initialized": False,
"initialization_completed": False,
"configured_count": 0,
"configured_categories": [],
"missing_categories": ["redis", "database", "keycloak"],
"next_action": "start_initialization",
"message": f"資料庫檢查失敗,請開始初始化: {str(e)}",
"traceback": traceback.format_exc()
}
@router.get("/health-check")
async def health_check():
"""
完整的健康檢查(已初始化系統使用)
Returns:
所有環境組件的檢測結果
"""
checker = EnvironmentChecker()
report = checker.check_all()
# 計算整體狀態
statuses = [comp["status"] for comp in report["components"].values()]
if all(s == "ok" for s in statuses):
report["overall_status"] = "healthy"
elif any(s == "error" for s in statuses):
report["overall_status"] = "unhealthy"
else:
report["overall_status"] = "degraded"
return report
# ==================== Phase 1: Redis 設定 ====================
@router.post("/test-redis")
async def test_redis_connection(config: RedisConfig):
"""
測試 Redis 連線
- 測試連線是否成功
- 測試 PING 命令
"""
checker = EnvironmentChecker()
result = checker.test_redis_connection(
host=config.host,
port=config.port,
password=config.password,
db=config.db
)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["error"]
)
return result
@router.get("/get-config/{category}")
async def get_saved_config(category: str, db: Session = Depends(deps.get_db)):
"""
讀取已儲存的環境配置
- category: redis, database, keycloak
- 回傳: 已儲存的配置資料 (敏感欄位會遮罩)
"""
from app.models.installation import InstallationEnvironmentConfig
configs = db.query(InstallationEnvironmentConfig).filter(
InstallationEnvironmentConfig.config_category == category,
InstallationEnvironmentConfig.is_configured == True
).all()
if not configs:
return {
"configured": False,
"config": {}
}
# 將配置轉換為字典
config_dict = {}
for cfg in configs:
# 移除前綴 (例如 REDIS_HOST → host)
# 先移除前綴,再轉小寫
key = cfg.config_key.replace(f"{category.upper()}_", "").lower()
# 敏感欄位不回傳實際值
if cfg.is_sensitive:
config_dict[key] = "****" if cfg.config_value else ""
else:
config_dict[key] = cfg.config_value
return {
"configured": True,
"config": config_dict
}
@router.post("/setup-redis")
async def setup_redis(config: RedisConfig, db: Session = Depends(deps.get_db)):
"""
設定 Redis
1. 測試連線
2. 寫入 .env
3. 寫入資料庫 (installation_environment_config)
"""
from app.models.installation import InstallationEnvironmentConfig
from datetime import datetime
checker = EnvironmentChecker()
# 1. 測試連線
test_result = checker.test_redis_connection(
host=config.host,
port=config.port,
password=config.password,
db=config.db
)
if not test_result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result["error"]
)
# 2. 寫入 .env
update_env_file("REDIS_HOST", config.host)
update_env_file("REDIS_PORT", str(config.port))
if config.password:
update_env_file("REDIS_PASSWORD", config.password)
update_env_file("REDIS_DB", str(config.db))
# 3. 寫入資料庫
configs_to_save = [
{"key": "REDIS_HOST", "value": config.host, "sensitive": False},
{"key": "REDIS_PORT", "value": str(config.port), "sensitive": False},
{"key": "REDIS_PASSWORD", "value": config.password or "", "sensitive": True},
{"key": "REDIS_DB", "value": str(config.db), "sensitive": False},
]
for cfg in configs_to_save:
existing = db.query(InstallationEnvironmentConfig).filter(
InstallationEnvironmentConfig.config_key == cfg["key"]
).first()
if existing:
existing.config_value = cfg["value"]
existing.is_sensitive = cfg["sensitive"]
existing.is_configured = True
existing.configured_at = datetime.now()
existing.updated_at = datetime.now()
else:
new_config = InstallationEnvironmentConfig(
config_key=cfg["key"],
config_value=cfg["value"],
config_category="redis",
is_sensitive=cfg["sensitive"],
is_configured=True,
configured_at=datetime.now()
)
db.add(new_config)
db.commit()
# 重新載入環境變數
os.environ["REDIS_HOST"] = config.host
os.environ["REDIS_PORT"] = str(config.port)
if config.password:
os.environ["REDIS_PASSWORD"] = config.password
os.environ["REDIS_DB"] = str(config.db)
return {
"success": True,
"message": "Redis 設定完成並已記錄至資料庫",
"next_step": "setup_database"
}
# ==================== Phase 2: 資料庫設定 ====================
@router.post("/test-database")
async def test_database_connection(config: DatabaseConfig):
"""
測試資料庫連線
- 測試連線是否成功
- 不寫入任何設定
"""
checker = EnvironmentChecker()
result = checker.test_database_connection(
host=config.host,
port=config.port,
database=config.database,
user=config.user,
password=config.password
)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["error"]
)
return result
@router.post("/setup-database")
async def setup_database(config: DatabaseConfig):
"""
設定資料庫並執行初始化
1. 測試連線
2. 寫入 .env
3. 執行 migrations
4. 建立預設租戶
"""
checker = EnvironmentChecker()
# 1. 測試連線
test_result = checker.test_database_connection(
host=config.host,
port=config.port,
database=config.database,
user=config.user,
password=config.password
)
if not test_result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result["error"]
)
# 2. 建立連線字串
connection_string = (
f"postgresql+psycopg2://{config.user}:{config.password}"
f"@{config.host}:{config.port}/{config.database}"
)
# 3. 寫入 .env
update_env_file("DATABASE_URL", connection_string)
# 重新載入環境變數
os.environ["DATABASE_URL"] = connection_string
# 4. 執行 migrations
try:
run_alembic_migrations()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"資料表建立失敗: {str(e)}"
)
# 5. 建立預設租戶(未初始化狀態)
from app.db.session import get_session_local
from app.models.installation import InstallationEnvironmentConfig
from datetime import datetime
SessionLocal = get_session_local()
db = SessionLocal()
try:
existing_tenant = db.query(Tenant).first()
if not existing_tenant:
tenant = Tenant(
code='temp',
name='待設定',
keycloak_realm='temp',
is_initialized=False
)
db.add(tenant)
db.commit()
db.refresh(tenant)
tenant_id = tenant.id
else:
tenant_id = existing_tenant.id
# 6. 寫入資料庫配置記錄
configs_to_save = [
{"key": "DATABASE_URL", "value": connection_string, "sensitive": True},
{"key": "DATABASE_HOST", "value": config.host, "sensitive": False},
{"key": "DATABASE_PORT", "value": str(config.port), "sensitive": False},
{"key": "DATABASE_NAME", "value": config.database, "sensitive": False},
{"key": "DATABASE_USER", "value": config.user, "sensitive": False},
]
for cfg in configs_to_save:
existing = db.query(InstallationEnvironmentConfig).filter(
InstallationEnvironmentConfig.config_key == cfg["key"]
).first()
if existing:
existing.config_value = cfg["value"]
existing.is_sensitive = cfg["sensitive"]
existing.is_configured = True
existing.configured_at = datetime.now()
existing.updated_at = datetime.now()
else:
new_config = InstallationEnvironmentConfig(
config_key=cfg["key"],
config_value=cfg["value"],
config_category="database",
is_sensitive=cfg["sensitive"],
is_configured=True,
configured_at=datetime.now()
)
db.add(new_config)
db.commit()
finally:
db.close()
return {
"success": True,
"message": "資料庫設定完成並已記錄",
"tenant_id": tenant_id,
"next_step": "setup_keycloak"
}
# ==================== Phase 3: Keycloak 設定 ====================
@router.post("/test-keycloak")
async def test_keycloak_connection(config: KeycloakConfig):
"""
測試 Keycloak 連線
- 測試服務是否運行
- 驗證管理員權限
- 檢查 Realm 是否存在
"""
checker = EnvironmentChecker()
result = checker.test_keycloak_connection(
url=config.url,
realm=config.realm,
admin_username=config.admin_username,
admin_password=config.admin_password
)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["error"]
)
return result
@router.post("/setup-keycloak")
async def setup_keycloak(config: KeycloakConfig, db: Session = Depends(deps.get_db)):
"""
設定 Keycloak
1. 測試連線
2. 寫入 .env
3. 寫入資料庫
4. 建立 Realm (如果不存在)
5. 建立 Clients
"""
from app.models.installation import InstallationEnvironmentConfig
from datetime import datetime
checker = EnvironmentChecker()
# 1. 測試連線
test_result = checker.test_keycloak_connection(
url=config.url,
realm=config.realm,
admin_username=config.admin_username,
admin_password=config.admin_password
)
if not test_result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=test_result["error"]
)
# 2. 寫入 .env
update_env_file("KEYCLOAK_URL", config.url)
update_env_file("KEYCLOAK_REALM", config.realm)
update_env_file("KEYCLOAK_ADMIN_USERNAME", config.admin_username)
update_env_file("KEYCLOAK_ADMIN_PASSWORD", config.admin_password)
# 3. 寫入資料庫
configs_to_save = [
{"key": "KEYCLOAK_URL", "value": config.url, "sensitive": False},
{"key": "KEYCLOAK_REALM", "value": config.realm, "sensitive": False},
{"key": "KEYCLOAK_ADMIN_USERNAME", "value": config.admin_username, "sensitive": False},
{"key": "KEYCLOAK_ADMIN_PASSWORD", "value": config.admin_password, "sensitive": True},
]
for cfg in configs_to_save:
existing = db.query(InstallationEnvironmentConfig).filter(
InstallationEnvironmentConfig.config_key == cfg["key"]
).first()
if existing:
existing.config_value = cfg["value"]
existing.is_sensitive = cfg["sensitive"]
existing.is_configured = True
existing.configured_at = datetime.now()
existing.updated_at = datetime.now()
else:
new_config = InstallationEnvironmentConfig(
config_key=cfg["key"],
config_value=cfg["value"],
config_category="keycloak",
is_sensitive=cfg["sensitive"],
is_configured=True,
configured_at=datetime.now()
)
db.add(new_config)
db.commit()
# 重新載入環境變數
os.environ["KEYCLOAK_URL"] = config.url
os.environ["KEYCLOAK_REALM"] = config.realm
# 4. 建立/驗證 Realm 和 Clients
from app.services.keycloak_service import KeycloakService
kc_service = KeycloakService()
try:
# 這裡可以加入自動建立 Realm 和 Clients 的邏輯
# 目前先假設 Keycloak 已手動設定
pass
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Keycloak 設定失敗: {str(e)}"
)
return {
"success": True,
"message": "Keycloak 設定完成並已記錄",
"realm_exists": test_result["realm_exists"],
"next_step": "setup_company_info"
}
# ==================== Phase 4: 公司資訊設定 ====================
@router.post("/sessions")
async def create_installation_session(
environment: str = "production",
db: Session = Depends(deps.get_db)
):
"""
建立安裝會話
- 開始初始化流程前必須先建立會話
- 初始化時租戶尚未建立,所以 tenant_id 為 None
"""
service = InstallationService(db)
session = service.create_session(
tenant_id=None, # 初始化時還沒有租戶
environment=environment,
executed_by='installer'
)
return {
"session_id": session.id,
"tenant_id": session.tenant_id,
"status": session.status
}
@router.post("/sessions/{session_id}/tenant-info")
async def save_tenant_info(
session_id: int,
data: TenantInfoInput,
db: Session = Depends(deps.get_db)
):
"""
儲存公司資訊
- 填寫完畢後即時儲存
- 可重複呼叫更新
"""
service = InstallationService(db)
try:
tenant_info = service.save_tenant_info(
session_id=session_id,
tenant_info_data=data.dict()
)
return {
"success": True,
"message": "公司資訊已儲存",
"next_step": "setup_admin"
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
# ==================== Phase 5: 管理員設定 ====================
@router.post("/sessions/{session_id}/admin-setup")
async def setup_admin_credentials(
session_id: int,
data: AdminSetupInput,
db: Session = Depends(deps.get_db)
):
"""
設定系統管理員並產生初始密碼
- 產生臨時密碼
- 返回明文密碼(僅此一次)
"""
service = InstallationService(db)
try:
# 預設資訊
admin_data = {
"admin_employee_id": "ADMIN001",
"admin_username": "admin",
"admin_legal_name": data.admin_legal_name,
"admin_english_name": data.admin_english_name,
"admin_email": data.admin_email,
"admin_phone": data.admin_phone
}
tenant_info, initial_password = service.setup_admin_credentials(
session_id=session_id,
admin_data=admin_data,
password_method=data.password_method,
manual_password=data.manual_password
)
return {
"success": True,
"message": "管理員已設定",
"username": "admin",
"email": data.admin_email,
"initial_password": initial_password, # ⚠️ 僅返回一次
"password_method": data.password_method,
"next_step": "setup_departments"
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# ==================== Phase 6: 部門設定 ====================
@router.post("/sessions/{session_id}/departments")
async def setup_departments(
session_id: int,
departments: list[DepartmentSetupInput],
db: Session = Depends(deps.get_db)
):
"""
設定部門架構
- 可一次設定多個部門
"""
service = InstallationService(db)
try:
dept_setups = service.setup_departments(
session_id=session_id,
departments_data=[d.dict() for d in departments]
)
return {
"success": True,
"message": f"已設定 {len(dept_setups)} 個部門",
"next_step": "execute_initialization"
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
# ==================== Phase 7: 執行初始化 ====================
@router.post("/sessions/{session_id}/execute")
async def execute_initialization(
session_id: int,
db: Session = Depends(deps.get_db)
):
"""
執行完整的初始化流程
1. 更新租戶資料
2. 建立部門
3. 建立管理員員工
4. 建立 Keycloak 用戶
5. 分配系統管理員角色
6. 標記完成並鎖定
"""
service = InstallationService(db)
try:
results = service.execute_initialization(session_id)
return {
"success": True,
"message": "初始化完成",
"results": results,
"next_step": "redirect_to_login"
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
# ==================== 查詢與管理 ====================
@router.get("/sessions/{session_id}")
async def get_installation_session(
session_id: int,
db: Session = Depends(deps.get_db)
):
"""
取得安裝會話詳細資訊
- 如果已鎖定,敏感資訊將被隱藏
"""
service = InstallationService(db)
try:
details = service.get_session_details(
session_id=session_id,
include_sensitive=False # 預設不包含密碼
)
return details
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
@router.post("/sessions/{session_id}/clear-password")
async def clear_plain_password(
session_id: int,
db: Session = Depends(deps.get_db)
):
"""
清除臨時密碼的明文
- 使用者確認已複製密碼後呼叫
"""
service = InstallationService(db)
cleared = service.clear_plain_password(
session_id=session_id,
reason='user_confirmed'
)
return {
"success": cleared,
"message": "明文密碼已清除" if cleared else "找不到需要清除的密碼"
}
# ==================== 輔助函數 ====================
def update_env_file(key: str, value: str):
"""
更新 .env 檔案
- 如果 key 已存在,更新值
- 如果不存在,新增一行
"""
env_path = os.path.join(os.getcwd(), '.env')
# 讀取現有內容
lines = []
key_found = False
if os.path.exists(env_path):
with open(env_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 更新現有 key
for i, line in enumerate(lines):
if line.startswith(f"{key}="):
lines[i] = f"{key}={value}\n"
key_found = True
break
# 如果 key 不存在,新增
if not key_found:
lines.append(f"{key}={value}\n")
# 寫回檔案
with open(env_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
def run_alembic_migrations():
"""
執行 Alembic migrations
- 使用 subprocess 呼叫 alembic upgrade head
- Windows 環境下使用 Python 模組調用方式
"""
import subprocess
import sys
try:
result = subprocess.run(
[sys.executable, '-m', 'alembic', 'upgrade', 'head'],
cwd=os.getcwd(),
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
raise Exception(f"Alembic 執行失敗: {result.stderr}")
return result.stdout
except subprocess.TimeoutExpired:
raise Exception("Alembic 執行逾時")
except Exception as e:
raise Exception(f"Alembic 執行錯誤: {str(e)}")
# ==================== 開發測試工具 ====================
@router.delete("/reset-config/{category}")
async def reset_environment_config(
category: str,
db: Session = Depends(deps.get_db)
):
"""
重置環境配置(開發測試用)
- category: redis, database, keycloak, 或 all
- 刪除對應的配置記錄
"""
from app.models.installation import InstallationEnvironmentConfig
if category == "all":
# 刪除所有配置
db.query(InstallationEnvironmentConfig).delete()
db.commit()
return {"success": True, "message": "已重置所有環境配置"}
else:
# 刪除特定分類的配置
deleted = db.query(InstallationEnvironmentConfig).filter(
InstallationEnvironmentConfig.config_category == category
).delete()
db.commit()
if deleted > 0:
return {"success": True, "message": f"已重置 {category} 配置 ({deleted} 筆記錄)"}
else:
return {"success": False, "message": f"找不到 {category} 的配置記錄"}
# ==================== 系統階段轉換 ====================

View File

@@ -0,0 +1,290 @@
"""
系統階段轉換 API
處理三階段狀態轉換Initialization → Operational ↔ Transition
"""
import os
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from datetime import datetime
from app.api import deps
router = APIRouter()
@router.post("/complete-initialization")
async def complete_initialization(db: Session = Depends(deps.get_db)):
"""
完成初始化,將系統狀態從 Initialization 轉換到 Operational
條件檢查:
1. 必須已完成 Redis, Database, Keycloak 設定
2. 必須已建立公司資訊
3. 必須已建立管理員帳號
執行操作:
1. 更新 installation_system_status
2. 將 current_phase 從 'initialization' 改為 'operational'
3. 設定 initialization_completed = True
4. 記錄 initialized_at, operational_since
"""
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig, InstallationTenantInfo
try:
# 1. 取得系統狀態
system_status = db.query(InstallationSystemStatus).first()
if not system_status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="系統狀態記錄不存在"
)
if system_status.current_phase != "initialization":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"系統當前階段為 {system_status.current_phase},無法執行此操作"
)
# 2. 檢查必要配置是否完成
required_categories = ["redis", "database", "keycloak"]
configured_categories = db.query(InstallationEnvironmentConfig.config_category).filter(
InstallationEnvironmentConfig.is_configured == True
).distinct().all()
configured_categories = [cat[0] for cat in configured_categories]
missing = [cat for cat in required_categories if cat not in configured_categories]
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"尚未完成環境配置: {', '.join(missing)}"
)
# 3. 檢查是否已建立租戶資訊
tenant_info = db.query(InstallationTenantInfo).first()
if not tenant_info or not tenant_info.is_completed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="尚未完成公司資訊設定"
)
# 4. 更新系統狀態
now = datetime.now()
system_status.previous_phase = system_status.current_phase
system_status.current_phase = "operational"
system_status.phase_changed_at = now
system_status.phase_change_reason = "初始化完成,進入營運階段"
system_status.initialization_completed = True
system_status.initialized_at = now
system_status.operational_since = now
system_status.updated_at = now
db.commit()
db.refresh(system_status)
return {
"success": True,
"message": "系統初始化完成,已進入營運階段",
"current_phase": system_status.current_phase,
"operational_since": system_status.operational_since.isoformat(),
"next_action": "redirect_to_login"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"完成初始化失敗: {str(e)}"
)
@router.post("/switch-phase")
async def switch_phase(
target_phase: str,
reason: str = None,
db: Session = Depends(deps.get_db)
):
"""
切換系統階段Operational ↔ Transition
Args:
target_phase: operational | transition
reason: 切換原因
Rules:
- operational → transition: 需進行系統遷移時
- transition → operational: 完成遷移並通過一致性檢查後
"""
from app.models.installation import InstallationSystemStatus
if target_phase not in ["operational", "transition"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="target_phase 必須為 'operational''transition'"
)
try:
system_status = db.query(InstallationSystemStatus).first()
if not system_status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="系統狀態記錄不存在"
)
# 不允許從 initialization 直接切換
if system_status.current_phase == "initialization":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="初始化階段無法直接切換,請先完成初始化"
)
# 檢查是否已是目標階段
if system_status.current_phase == target_phase:
return {
"success": True,
"message": f"系統已處於 {target_phase} 階段",
"current_phase": target_phase
}
# 特殊檢查:從 transition 回到 operational 必須通過一致性檢查
if system_status.current_phase == "transition" and target_phase == "operational":
if not system_status.env_db_consistent:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="環境與資料庫不一致,無法切換回營運階段"
)
# 執行切換
now = datetime.now()
system_status.previous_phase = system_status.current_phase
system_status.current_phase = target_phase
system_status.phase_changed_at = now
system_status.phase_change_reason = reason or f"手動切換至 {target_phase} 階段"
# 根據目標階段更新相關欄位
if target_phase == "transition":
system_status.transition_started_at = now
system_status.env_db_consistent = None # 重置一致性狀態
system_status.inconsistencies = None
elif target_phase == "operational":
system_status.operational_since = now
system_status.updated_at = now
db.commit()
db.refresh(system_status)
return {
"success": True,
"message": f"已切換至 {target_phase} 階段",
"current_phase": system_status.current_phase,
"previous_phase": system_status.previous_phase,
"phase_changed_at": system_status.phase_changed_at.isoformat()
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"階段切換失敗: {str(e)}"
)
@router.post("/check-consistency")
async def check_env_db_consistency(db: Session = Depends(deps.get_db)):
"""
檢查 .env 檔案與資料庫配置的一致性Transition 階段使用)
比對項目:
- REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB
- DATABASE_URL, DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER
- KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_ADMIN_USERNAME
Returns:
is_consistent: True/False
inconsistencies: 不一致項目列表
"""
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig
try:
system_status = db.query(InstallationSystemStatus).first()
if not system_status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="系統狀態記錄不存在"
)
# 從資料庫讀取配置
db_configs = {}
config_records = db.query(InstallationEnvironmentConfig).filter(
InstallationEnvironmentConfig.is_configured == True
).all()
for record in config_records:
db_configs[record.config_key] = record.config_value
# 從 .env 讀取配置
env_configs = {}
env_file_path = os.path.join(os.getcwd(), '.env')
if os.path.exists(env_file_path):
with open(env_file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
env_configs[key.strip()] = value.strip()
# 比對差異(排除敏感資訊的顯示)
inconsistencies = []
checked_keys = set(db_configs.keys()) | set(env_configs.keys())
for key in checked_keys:
db_value = db_configs.get(key, "[NOT SET]")
env_value = env_configs.get(key, "[NOT SET]")
if db_value != env_value:
# 檢查是否為敏感資訊
is_sensitive = any(sensitive in key.lower() for sensitive in ['password', 'secret', 'key'])
inconsistencies.append({
"config_key": key,
"db_value": "[HIDDEN]" if is_sensitive else db_value,
"env_value": "[HIDDEN]" if is_sensitive else env_value,
"is_sensitive": is_sensitive
})
is_consistent = len(inconsistencies) == 0
# 更新系統狀態
now = datetime.now()
system_status.env_db_consistent = is_consistent
system_status.consistency_checked_at = now
system_status.inconsistencies = json.dumps(inconsistencies, ensure_ascii=False) if inconsistencies else None
system_status.updated_at = now
db.commit()
return {
"is_consistent": is_consistent,
"checked_at": now.isoformat(),
"total_configs": len(checked_keys),
"inconsistency_count": len(inconsistencies),
"inconsistencies": inconsistencies,
"message": "環境配置一致" if is_consistent else f"發現 {len(inconsistencies)} 項不一致"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"一致性檢查失敗: {str(e)}"
)

View File

@@ -0,0 +1,364 @@
"""
員工身份管理 API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.employee import Employee
from app.models.employee_identity import EmployeeIdentity
from app.models.business_unit import BusinessUnit
from app.models.department import Department
from app.schemas.employee_identity import (
EmployeeIdentityCreate,
EmployeeIdentityUpdate,
EmployeeIdentityResponse,
EmployeeIdentityListItem,
)
from app.schemas.response import MessageResponse
router = APIRouter()
@router.get("/", response_model=List[EmployeeIdentityListItem])
def get_identities(
db: Session = Depends(get_db),
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
business_unit_id: Optional[int] = Query(None, description="事業部 ID 篩選"),
department_id: Optional[int] = Query(None, description="部門 ID 篩選"),
is_active: Optional[bool] = Query(None, description="是否活躍"),
):
"""
獲取員工身份列表
支援多種篩選條件
"""
query = db.query(EmployeeIdentity)
if employee_id:
query = query.filter(EmployeeIdentity.employee_id == employee_id)
if business_unit_id:
query = query.filter(EmployeeIdentity.business_unit_id == business_unit_id)
if department_id:
query = query.filter(EmployeeIdentity.department_id == department_id)
if is_active is not None:
query = query.filter(EmployeeIdentity.is_active == is_active)
identities = query.order_by(
EmployeeIdentity.employee_id,
EmployeeIdentity.is_primary.desc()
).all()
return [EmployeeIdentityListItem.model_validate(identity) for identity in identities]
@router.get("/{identity_id}", response_model=EmployeeIdentityResponse)
def get_identity(
identity_id: int,
db: Session = Depends(get_db),
):
"""
獲取員工身份詳情
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = identity.employee.legal_name
response.business_unit_name = identity.business_unit.name
response.email_domain = identity.business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response
@router.post("/", response_model=EmployeeIdentityResponse, status_code=status.HTTP_201_CREATED)
def create_identity(
identity_data: EmployeeIdentityCreate,
db: Session = Depends(get_db),
):
"""
創建員工身份
自動生成 SSO 帳號:
- 格式: {username_base}@{email_domain}
- 需要生成 Keycloak UUID (TODO)
檢查:
- 員工是否存在
- 事業部是否存在
- 部門是否存在 (如果指定)
- 同一員工在同一事業部只能有一個身份
"""
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == identity_data.employee_id
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {identity_data.employee_id} not found"
)
# 檢查事業部是否存在
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == identity_data.business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {identity_data.business_unit_id} not found"
)
# 檢查部門是否存在 (如果指定)
if identity_data.department_id:
department = db.query(Department).filter(
Department.id == identity_data.department_id,
Department.business_unit_id == identity_data.business_unit_id
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {identity_data.department_id} not found in this business unit"
)
# 檢查同一員工在同一事業部是否已有身份
existing = db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity_data.employee_id,
EmployeeIdentity.business_unit_id == identity_data.business_unit_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Employee already has an identity in this business unit"
)
# 生成 SSO 帳號
username = f"{employee.username_base}@{business_unit.email_domain}"
# 檢查 SSO 帳號是否已存在
existing_username = db.query(EmployeeIdentity).filter(
EmployeeIdentity.username == username
).first()
if existing_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Username '{username}' already exists"
)
# TODO: 從 Keycloak 創建帳號並獲取 UUID
keycloak_id = f"temp-uuid-{employee.id}-{business_unit.id}"
# 創建身份
identity = EmployeeIdentity(
employee_id=identity_data.employee_id,
username=username,
keycloak_id=keycloak_id,
business_unit_id=identity_data.business_unit_id,
department_id=identity_data.department_id,
job_title=identity_data.job_title,
job_level=identity_data.job_level,
is_primary=identity_data.is_primary,
email_quota_mb=identity_data.email_quota_mb,
started_at=identity_data.started_at,
)
# 如果設為主要身份,取消其他主要身份
if identity_data.is_primary:
db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity_data.employee_id
).update({"is_primary": False})
db.add(identity)
db.commit()
db.refresh(identity)
# TODO: 創建審計日誌
# TODO: 創建 Keycloak 帳號
# TODO: 創建郵件帳號
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = employee.legal_name
response.business_unit_name = business_unit.name
response.email_domain = business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response
@router.put("/{identity_id}", response_model=EmployeeIdentityResponse)
def update_identity(
identity_id: int,
identity_data: EmployeeIdentityUpdate,
db: Session = Depends(get_db),
):
"""
更新員工身份
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
# 檢查部門是否屬於同一事業部 (如果更新部門)
if identity_data.department_id:
department = db.query(Department).filter(
Department.id == identity_data.department_id,
Department.business_unit_id == identity.business_unit_id
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Department does not belong to this business unit"
)
# 更新欄位
update_data = identity_data.model_dump(exclude_unset=True)
# 如果設為主要身份,取消其他主要身份
if update_data.get("is_primary"):
db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity.employee_id,
EmployeeIdentity.id != identity_id
).update({"is_primary": False})
for field, value in update_data.items():
setattr(identity, field, value)
db.commit()
db.refresh(identity)
# TODO: 創建審計日誌
# TODO: 更新 NAS 配額 (如果職級變更)
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = identity.employee.legal_name
response.business_unit_name = identity.business_unit.name
response.email_domain = identity.business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response
@router.delete("/{identity_id}", response_model=MessageResponse)
def delete_identity(
identity_id: int,
db: Session = Depends(get_db),
):
"""
刪除員工身份
注意:
- 如果是員工的最後一個身份,無法刪除
- 刪除後會停用對應的 Keycloak 和郵件帳號
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
# 檢查是否為員工的最後一個身份
total_identities = db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity.employee_id
).count()
if total_identities == 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete employee's last identity. Please terminate the employee instead."
)
# 軟刪除 (停用)
identity.is_active = False
identity.ended_at = db.func.current_date()
db.commit()
# TODO: 創建審計日誌
# TODO: 停用 Keycloak 帳號
# TODO: 停用郵件帳號
return MessageResponse(
message=f"Identity '{identity.username}' has been deactivated"
)
@router.post("/{identity_id}/set-primary", response_model=EmployeeIdentityResponse)
def set_primary_identity(
identity_id: int,
db: Session = Depends(get_db),
):
"""
設定為主要身份
將指定的身份設為員工的主要身份,並取消其他身份的主要狀態
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
# 檢查身份是否已停用
if not identity.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot set inactive identity as primary"
)
# 取消同一員工的其他主要身份
db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity.employee_id,
EmployeeIdentity.id != identity_id
).update({"is_primary": False})
# 設為主要身份
identity.is_primary = True
db.commit()
db.refresh(identity)
# TODO: 創建審計日誌
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = identity.employee.legal_name
response.business_unit_name = identity.business_unit.name
response.email_domain = identity.business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response

View File

@@ -0,0 +1,176 @@
"""
員工生命週期管理 API
觸發員工到職、離職自動化流程
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.employee import Employee
from app.services.employee_lifecycle import get_employee_lifecycle_service
router = APIRouter()
@router.post("/employees/{employee_id}/onboard")
async def onboard_employee(
employee_id: int,
create_keycloak: bool = True,
create_email: bool = True,
create_drive: bool = True,
db: Session = Depends(get_db),
):
"""
觸發員工到職流程
自動執行:
- 建立 Keycloak SSO 帳號
- 建立主要郵件帳號
- 建立雲端硬碟帳號 (Drive Service非致命)
參數:
- create_keycloak: 是否建立 Keycloak 帳號 (預設: True)
- create_email: 是否建立郵件帳號 (預設: True)
- create_drive: 是否建立雲端硬碟帳號 (預設: TrueDrive Service 未上線時自動跳過)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"員工 ID {employee_id} 不存在"
)
if employee.status != "active":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"只能為在職員工執行到職流程 (目前狀態: {employee.status})"
)
lifecycle_service = get_employee_lifecycle_service()
results = await lifecycle_service.onboard_employee(
db=db,
employee=employee,
create_keycloak=create_keycloak,
create_email=create_email,
create_drive=create_drive,
)
return {
"message": "員工到職流程已觸發",
"employee": {
"id": employee.id,
"employee_id": employee.employee_id,
"legal_name": employee.legal_name,
},
"results": results,
}
@router.post("/employees/{employee_id}/offboard")
async def offboard_employee(
employee_id: int,
disable_keycloak: bool = True,
email_handling: str = "forward", # "forward" 或 "disable"
disable_drive: bool = True,
db: Session = Depends(get_db),
):
"""
觸發員工離職流程
自動執行:
- 停用 Keycloak SSO 帳號
- 處理郵件帳號 (轉發或停用)
- 停用雲端硬碟帳號 (Drive Service非致命)
參數:
- disable_keycloak: 是否停用 Keycloak 帳號 (預設: True)
- email_handling: 郵件處理方式 "forward""disable" (預設: forward)
- disable_drive: 是否停用雲端硬碟帳號 (預設: TrueDrive Service 未上線時自動跳過)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"員工 ID {employee_id} 不存在"
)
if email_handling not in ["forward", "disable"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="email_handling 必須是 'forward''disable'"
)
lifecycle_service = get_employee_lifecycle_service()
results = await lifecycle_service.offboard_employee(
db=db,
employee=employee,
disable_keycloak=disable_keycloak,
handle_email=email_handling,
disable_drive=disable_drive,
)
# 將員工狀態設為離職
employee.status = "terminated"
db.commit()
return {
"message": "員工離職流程已觸發",
"employee": {
"id": employee.id,
"employee_id": employee.employee_id,
"legal_name": employee.legal_name,
},
"results": results,
}
@router.get("/employees/{employee_id}/lifecycle-status")
async def get_lifecycle_status(
employee_id: int,
db: Session = Depends(get_db),
):
"""
查詢員工的生命週期狀態
回傳:
- Keycloak 帳號狀態
- 郵件帳號狀態
- 雲端硬碟帳號狀態 (Drive Service)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"員工 ID {employee_id} 不存在"
)
# TODO: 實際查詢各系統的帳號狀態
return {
"employee": {
"id": employee.id,
"employee_id": employee.employee_id,
"legal_name": employee.legal_name,
"status": employee.status,
},
"systems": {
"keycloak": {
"has_account": False,
"is_enabled": False,
"message": "尚未整合 Keycloak API",
},
"email": {
"has_account": False,
"email_address": f"{employee.username_base}@porscheworld.tw",
"message": "尚未整合 MailPlus API",
},
"drive": {
"has_account": False,
"drive_url": "https://drive.ease.taipei",
"message": "Drive Service 尚未上線",
},
},
}

View File

@@ -0,0 +1,262 @@
"""
網路硬碟管理 API
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.employee import Employee
from app.models.network_drive import NetworkDrive
from app.schemas.network_drive import (
NetworkDriveCreate,
NetworkDriveUpdate,
NetworkDriveResponse,
NetworkDriveListItem,
NetworkDriveQuotaUpdate,
)
from app.schemas.response import MessageResponse
router = APIRouter()
@router.get("/", response_model=List[NetworkDriveListItem])
def get_network_drives(
db: Session = Depends(get_db),
is_active: bool = True,
):
"""
獲取網路硬碟列表
"""
query = db.query(NetworkDrive)
if is_active is not None:
query = query.filter(NetworkDrive.is_active == is_active)
network_drives = query.order_by(NetworkDrive.drive_name).all()
return [NetworkDriveListItem.model_validate(nd) for nd in network_drives]
@router.get("/{network_drive_id}", response_model=NetworkDriveResponse)
def get_network_drive(
network_drive_id: int,
db: Session = Depends(get_db),
):
"""
獲取網路硬碟詳情
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = network_drive.employee.legal_name
response.employee_username = network_drive.employee.username_base
return response
@router.post("/", response_model=NetworkDriveResponse, status_code=status.HTTP_201_CREATED)
def create_network_drive(
network_drive_data: NetworkDriveCreate,
db: Session = Depends(get_db),
):
"""
創建網路硬碟
檢查:
- 員工是否存在
- 員工是否已有 NAS 帳號
- drive_name 唯一性
"""
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == network_drive_data.employee_id
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {network_drive_data.employee_id} not found"
)
# 檢查員工是否已有 NAS 帳號
existing = db.query(NetworkDrive).filter(
NetworkDrive.employee_id == network_drive_data.employee_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Employee already has a network drive"
)
# 檢查 drive_name 是否已存在
existing_name = db.query(NetworkDrive).filter(
NetworkDrive.drive_name == network_drive_data.drive_name
).first()
if existing_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drive name '{network_drive_data.drive_name}' already exists"
)
# 創建網路硬碟
network_drive = NetworkDrive(**network_drive_data.model_dump())
db.add(network_drive)
db.commit()
db.refresh(network_drive)
# TODO: 創建審計日誌
# TODO: 在 NAS 上創建實際帳號
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = employee.legal_name
response.employee_username = employee.username_base
return response
@router.put("/{network_drive_id}", response_model=NetworkDriveResponse)
def update_network_drive(
network_drive_id: int,
network_drive_data: NetworkDriveUpdate,
db: Session = Depends(get_db),
):
"""
更新網路硬碟
可更新: quota_gb, webdav_url, smb_url, is_active
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
# 更新欄位
update_data = network_drive_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(network_drive, field, value)
db.commit()
db.refresh(network_drive)
# TODO: 創建審計日誌
# TODO: 更新 NAS 配額
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = network_drive.employee.legal_name
response.employee_username = network_drive.employee.username_base
return response
@router.patch("/{network_drive_id}/quota", response_model=NetworkDriveResponse)
def update_network_drive_quota(
network_drive_id: int,
quota_data: NetworkDriveQuotaUpdate,
db: Session = Depends(get_db),
):
"""
更新網路硬碟配額
專用端點,僅更新配額
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
network_drive.quota_gb = quota_data.quota_gb
db.commit()
db.refresh(network_drive)
# TODO: 創建審計日誌
# TODO: 更新 NAS 配額
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = network_drive.employee.legal_name
response.employee_username = network_drive.employee.username_base
return response
@router.delete("/{network_drive_id}", response_model=MessageResponse)
def delete_network_drive(
network_drive_id: int,
db: Session = Depends(get_db),
):
"""
停用網路硬碟
注意: 這是軟刪除,只將 is_active 設為 False
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
network_drive.is_active = False
db.commit()
# TODO: 創建審計日誌
# TODO: 停用 NAS 帳號 (但保留資料)
return MessageResponse(
message=f"Network drive '{network_drive.drive_name}' has been deactivated"
)
@router.get("/by-employee/{employee_id}", response_model=NetworkDriveResponse)
def get_network_drive_by_employee(
employee_id: int,
db: Session = Depends(get_db),
):
"""
根據員工 ID 獲取網路硬碟
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
if not employee.network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee does not have a network drive"
)
response = NetworkDriveResponse.model_validate(employee.network_drive)
response.employee_name = employee.legal_name
response.employee_username = employee.username_base
return response

View File

@@ -0,0 +1,542 @@
"""
系統權限管理 API
管理員工對各系統 (Gitea, Portainer, Traefik, Keycloak) 的存取權限
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_
from app.db.session import get_db
from app.models.employee import Employee
from app.models.permission import Permission
from app.schemas.permission import (
PermissionCreate,
PermissionUpdate,
PermissionResponse,
PermissionListItem,
PermissionBatchCreate,
PermissionFilter,
VALID_SYSTEMS,
VALID_ACCESS_LEVELS,
)
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_permissions(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
filter_params: PermissionFilter = Depends(),
):
"""
獲取權限列表
支援:
- 分頁
- 員工篩選
- 系統名稱篩選
- 存取層級篩選
"""
tenant_id = get_current_tenant_id()
query = db.query(Permission).filter(Permission.tenant_id == tenant_id)
# 員工篩選
if filter_params.employee_id:
query = query.filter(Permission.employee_id == filter_params.employee_id)
# 系統名稱篩選
if filter_params.system_name:
query = query.filter(Permission.system_name == filter_params.system_name)
# 存取層級篩選
if filter_params.access_level:
query = query.filter(Permission.access_level == filter_params.access_level)
# 總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
permissions = (
query.options(joinedload(Permission.employee))
.offset(offset)
.limit(pagination.page_size)
.all()
)
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
# 組裝回應資料
items = []
for perm in permissions:
item = PermissionListItem.model_validate(perm)
item.employee_name = perm.employee.legal_name if perm.employee else None
item.employee_number = perm.employee.employee_id if perm.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("/systems", response_model=dict)
def get_available_systems_route():
"""
取得所有可授權的系統列表 (必須在 /{permission_id} 之前定義)
"""
return {
"systems": VALID_SYSTEMS,
"access_levels": VALID_ACCESS_LEVELS,
"system_descriptions": {
"gitea": "Git 程式碼託管系統",
"portainer": "Docker 容器管理系統",
"traefik": "反向代理與路由系統",
"keycloak": "SSO 身份認證系統",
},
"access_level_descriptions": {
"admin": "完整管理權限",
"user": "一般使用者權限",
"readonly": "唯讀權限",
},
}
@router.get("/{permission_id}", response_model=PermissionResponse)
def get_permission(
permission_id: int,
db: Session = Depends(get_db),
):
"""
獲取權限詳情
"""
tenant_id = get_current_tenant_id()
permission = (
db.query(Permission)
.options(
joinedload(Permission.employee),
joinedload(Permission.granter),
)
.filter(
Permission.id == permission_id,
Permission.tenant_id == tenant_id,
)
.first()
)
if not permission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Permission with id {permission_id} not found",
)
# 組裝回應資料
response = PermissionResponse.model_validate(permission)
response.employee_name = permission.employee.legal_name if permission.employee else None
response.employee_number = permission.employee.employee_id if permission.employee else None
response.granted_by_name = permission.granter.legal_name if permission.granter else None
return response
@router.post("/", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED)
def create_permission(
permission_data: PermissionCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
創建權限
注意:
- 員工必須存在
- 每個員工對每個系統只能有一個權限 (unique constraint)
- 系統名稱: gitea, portainer, traefik, keycloak
- 存取層級: admin, user, readonly
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == permission_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 {permission_data.employee_id} not found",
)
# 檢查是否已有該系統的權限
existing = db.query(Permission).filter(
and_(
Permission.employee_id == permission_data.employee_id,
Permission.system_name == permission_data.system_name,
Permission.tenant_id == tenant_id,
)
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Permission for system '{permission_data.system_name}' already exists for this employee",
)
# 檢查授予人是否存在 (如果有提供)
if permission_data.granted_by:
granter = db.query(Employee).filter(
Employee.id == permission_data.granted_by,
Employee.tenant_id == tenant_id,
).first()
if not granter:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Granter with id {permission_data.granted_by} not found",
)
# 創建權限
permission = Permission(
tenant_id=tenant_id,
employee_id=permission_data.employee_id,
system_name=permission_data.system_name,
access_level=permission_data.access_level,
granted_by=permission_data.granted_by,
)
db.add(permission)
db.commit()
db.refresh(permission)
# 重新載入關聯資料
db.refresh(permission)
permission = (
db.query(Permission)
.options(
joinedload(Permission.employee),
joinedload(Permission.granter),
)
.filter(Permission.id == permission.id)
.first()
)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="create_permission",
resource_type="permission",
resource_id=permission.id,
details={
"employee_id": permission.employee_id,
"system_name": permission.system_name,
"access_level": permission.access_level,
"granted_by": permission.granted_by,
},
)
# 組裝回應資料
response = PermissionResponse.model_validate(permission)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
response.granted_by_name = permission.granter.legal_name if permission.granter else None
return response
@router.put("/{permission_id}", response_model=PermissionResponse)
def update_permission(
permission_id: int,
permission_data: PermissionUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新權限
可更新:
- 存取層級 (admin, user, readonly)
- 授予人
"""
tenant_id = get_current_tenant_id()
permission = (
db.query(Permission)
.options(
joinedload(Permission.employee),
joinedload(Permission.granter),
)
.filter(
Permission.id == permission_id,
Permission.tenant_id == tenant_id,
)
.first()
)
if not permission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Permission with id {permission_id} not found",
)
# 檢查授予人是否存在 (如果有提供)
if permission_data.granted_by:
granter = db.query(Employee).filter(
Employee.id == permission_data.granted_by,
Employee.tenant_id == tenant_id,
).first()
if not granter:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Granter with id {permission_data.granted_by} not found",
)
# 記錄變更前的值
old_access_level = permission.access_level
old_granted_by = permission.granted_by
# 更新欄位
permission.access_level = permission_data.access_level
if permission_data.granted_by is not None:
permission.granted_by = permission_data.granted_by
db.commit()
db.refresh(permission)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_permission",
resource_type="permission",
resource_id=permission.id,
details={
"employee_id": permission.employee_id,
"system_name": permission.system_name,
"changes": {
"access_level": {"from": old_access_level, "to": permission.access_level},
"granted_by": {"from": old_granted_by, "to": permission.granted_by},
},
},
)
# 組裝回應資料
response = PermissionResponse.model_validate(permission)
response.employee_name = permission.employee.legal_name if permission.employee else None
response.employee_number = permission.employee.employee_id if permission.employee else None
response.granted_by_name = permission.granter.legal_name if permission.granter else None
return response
@router.delete("/{permission_id}", response_model=MessageResponse)
def delete_permission(
permission_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
刪除權限
撤銷員工對某系統的存取權限
"""
tenant_id = get_current_tenant_id()
permission = db.query(Permission).filter(
Permission.id == permission_id,
Permission.tenant_id == tenant_id,
).first()
if not permission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Permission with id {permission_id} not found",
)
# 記錄審計日誌 (在刪除前)
audit_service.log_action(
request=request,
db=db,
action="delete_permission",
resource_type="permission",
resource_id=permission.id,
details={
"employee_id": permission.employee_id,
"system_name": permission.system_name,
"access_level": permission.access_level,
},
)
# 刪除權限
db.delete(permission)
db.commit()
return MessageResponse(
message=f"Permission for system {permission.system_name} has been revoked"
)
@router.get("/employees/{employee_id}/permissions", response_model=List[PermissionResponse])
def get_employee_permissions(
employee_id: int,
db: Session = Depends(get_db),
):
"""
取得員工的所有系統權限
回傳該員工可以存取的所有系統及其權限層級
"""
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",
)
# 查詢員工的所有權限
permissions = (
db.query(Permission)
.options(joinedload(Permission.granter))
.filter(
Permission.employee_id == employee_id,
Permission.tenant_id == tenant_id,
)
.all()
)
# 組裝回應資料
result = []
for perm in permissions:
response = PermissionResponse.model_validate(perm)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
response.granted_by_name = perm.granter.legal_name if perm.granter else None
result.append(response)
return result
@router.post("/batch", response_model=List[PermissionResponse], status_code=status.HTTP_201_CREATED)
def create_permissions_batch(
batch_data: PermissionBatchCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
批量創建權限
一次為一個員工授予多個系統的權限
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == batch_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 {batch_data.employee_id} not found",
)
# 檢查授予人是否存在 (如果有提供)
granter = None
if batch_data.granted_by:
granter = db.query(Employee).filter(
Employee.id == batch_data.granted_by,
Employee.tenant_id == tenant_id,
).first()
if not granter:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Granter with id {batch_data.granted_by} not found",
)
# 創建權限列表
created_permissions = []
for perm_data in batch_data.permissions:
# 檢查是否已有該系統的權限
existing = db.query(Permission).filter(
and_(
Permission.employee_id == batch_data.employee_id,
Permission.system_name == perm_data.system_name,
Permission.tenant_id == tenant_id,
)
).first()
if existing:
# 跳過已存在的權限
continue
# 創建權限
permission = Permission(
tenant_id=tenant_id,
employee_id=batch_data.employee_id,
system_name=perm_data.system_name,
access_level=perm_data.access_level,
granted_by=batch_data.granted_by,
)
db.add(permission)
created_permissions.append(permission)
db.commit()
# 刷新所有創建的權限
for perm in created_permissions:
db.refresh(perm)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="create_permissions_batch",
resource_type="permission",
resource_id=batch_data.employee_id,
details={
"employee_id": batch_data.employee_id,
"granted_by": batch_data.granted_by,
"permissions": [
{
"system_name": perm.system_name,
"access_level": perm.access_level,
}
for perm in created_permissions
],
},
)
# 組裝回應資料
result = []
for perm in created_permissions:
response = PermissionResponse.model_validate(perm)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
response.granted_by_name = granter.legal_name if granter else None
result.append(response)
return result

View 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
]

389
backend/app/api/v1/roles.py Normal file
View File

@@ -0,0 +1,389 @@
"""
角色管理 API (RBAC)
- roles: 租戶層級角色 (不綁定部門)
- role_rights: 角色對系統功能的 CRUD 權限
- user_role_assignments: 使用者角色分配 (直接對人,跨部門有效)
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.api.deps import get_tenant_id, get_current_tenant
from app.models.role import UserRole, RoleRight, UserRoleAssignment
from app.models.system_function_cache import SystemFunctionCache
from app.schemas.response import MessageResponse
from app.services.audit_service import audit_service
router = APIRouter()
# ========================
# 角色 CRUD
# ========================
@router.get("/")
def get_roles(
db: Session = Depends(get_db),
include_inactive: bool = False,
):
"""取得租戶的所有角色"""
tenant_id = get_current_tenant_id()
query = db.query(Role).filter(Role.tenant_id == tenant_id)
if not include_inactive:
query = query.filter(Role.is_active == True)
roles = query.order_by(Role.id).all()
return [
{
"id": r.id,
"role_code": r.role_code,
"role_name": r.role_name,
"description": r.description,
"is_active": r.is_active,
"rights_count": len(r.rights),
}
for r in roles
]
@router.get("/{role_id}")
def get_role(
role_id: int,
db: Session = Depends(get_db),
):
"""取得角色詳情(含功能權限)"""
tenant_id = get_current_tenant_id()
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
return {
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"description": role.description,
"is_active": role.is_active,
"rights": [
{
"function_id": r.function_id,
"function_code": r.function.function_code if r.function else None,
"function_name": r.function.function_name if r.function else None,
"service_code": r.function.service_code if r.function else None,
"can_read": r.can_read,
"can_create": r.can_create,
"can_update": r.can_update,
"can_delete": r.can_delete,
}
for r in role.rights
],
}
@router.post("/", status_code=status.HTTP_201_CREATED)
def create_role(
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
建立角色
Body: { "role_code": "WAREHOUSE_MANAGER", "role_name": "倉管角色", "description": "..." }
"""
tenant_id = get_current_tenant_id()
role_code = data.get("role_code", "").upper()
role_name = data.get("role_name", "")
if not role_code or not role_name:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="role_code and role_name are required"
)
existing = db.query(Role).filter(
Role.tenant_id == tenant_id,
Role.role_code == role_code,
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role code '{role_code}' already exists"
)
role = Role(
tenant_id=tenant_id,
role_code=role_code,
role_name=role_name,
description=data.get("description"),
)
db.add(role)
db.commit()
db.refresh(role)
audit_service.log_action(
request=request, db=db,
action="create_role", resource_type="role", resource_id=role.id,
details={"role_code": role_code, "role_name": role_name},
)
return {"id": role.id, "role_code": role.role_code, "role_name": role.role_name}
@router.delete("/{role_id}", response_model=MessageResponse)
def deactivate_role(
role_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""停用角色"""
tenant_id = get_current_tenant_id()
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
role.is_active = False
db.commit()
return MessageResponse(message=f"Role '{role.role_name}' has been deactivated")
# ========================
# 角色功能權限
# ========================
@router.put("/{role_id}/rights")
def set_role_rights(
role_id: int,
rights: list,
request: Request,
db: Session = Depends(get_db),
):
"""
設定角色的功能權限 (整體替換)
Body: [
{"function_id": 1, "can_read": true, "can_create": false, "can_update": false, "can_delete": false},
...
]
"""
tenant_id = get_current_tenant_id()
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
# 刪除舊的權限
db.query(RoleRight).filter(RoleRight.role_id == role_id).delete()
# 新增新的權限
for r in rights:
function_id = r.get("function_id")
fn = db.query(SystemFunctionCache).filter(
SystemFunctionCache.id == function_id
).first()
if not fn:
continue
right = RoleRight(
role_id=role_id,
function_id=function_id,
can_read=r.get("can_read", False),
can_create=r.get("can_create", False),
can_update=r.get("can_update", False),
can_delete=r.get("can_delete", False),
)
db.add(right)
db.commit()
audit_service.log_action(
request=request, db=db,
action="update_role_rights", resource_type="role", resource_id=role_id,
details={"rights_count": len(rights)},
)
return {"message": f"Role rights updated", "rights_count": len(rights)}
# ========================
# 使用者角色分配
# ========================
@router.get("/user-assignments/")
def get_user_role_assignments(
db: Session = Depends(get_db),
keycloak_user_id: Optional[str] = Query(None, description="Keycloak User UUID"),
):
"""取得使用者角色分配"""
tenant_id = get_current_tenant_id()
query = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.is_active == True,
)
if keycloak_user_id:
query = query.filter(UserRoleAssignment.keycloak_user_id == keycloak_user_id)
assignments = query.all()
return [
{
"id": a.id,
"keycloak_user_id": a.keycloak_user_id,
"role_id": a.role_id,
"role_code": a.role.role_code if a.role else None,
"role_name": a.role.role_name if a.role else None,
"assigned_at": a.assigned_at,
}
for a in assignments
]
@router.post("/user-assignments/", status_code=status.HTTP_201_CREATED)
def assign_role_to_user(
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
分配角色給使用者 (直接對人,跨部門有效)
Body: { "keycloak_user_id": "uuid", "role_id": 1 }
"""
tenant_id = get_current_tenant_id()
keycloak_user_id = data.get("keycloak_user_id")
role_id = data.get("role_id")
if not keycloak_user_id or not role_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="keycloak_user_id and role_id are required"
)
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
existing = db.query(UserRoleAssignment).filter(
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.role_id == role_id,
).first()
if existing:
if existing.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role assigned"
)
existing.is_active = True
db.commit()
return {"message": "Role assignment reactivated", "id": existing.id}
assignment = UserRoleAssignment(
tenant_id=tenant_id,
keycloak_user_id=keycloak_user_id,
role_id=role_id,
)
db.add(assignment)
db.commit()
db.refresh(assignment)
audit_service.log_action(
request=request, db=db,
action="assign_role", resource_type="user_role_assignment", resource_id=assignment.id,
details={"keycloak_user_id": keycloak_user_id, "role_id": role_id, "role_code": role.role_code},
)
return {"id": assignment.id, "keycloak_user_id": keycloak_user_id, "role_id": role_id}
@router.delete("/user-assignments/{assignment_id}", response_model=MessageResponse)
def revoke_role_from_user(
assignment_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""撤銷使用者角色"""
tenant_id = get_current_tenant_id()
assignment = db.query(UserRoleAssignment).filter(
UserRoleAssignment.id == assignment_id,
UserRoleAssignment.tenant_id == tenant_id,
).first()
if not assignment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Assignment with id {assignment_id} not found"
)
assignment.is_active = False
db.commit()
audit_service.log_action(
request=request, db=db,
action="revoke_role", resource_type="user_role_assignment", resource_id=assignment_id,
details={"keycloak_user_id": assignment.keycloak_user_id, "role_id": assignment.role_id},
)
return MessageResponse(message="Role assignment revoked")
# ========================
# 系統功能查詢
# ========================
@router.get("/system-functions/")
def get_system_functions(
db: Session = Depends(get_db),
service_code: Optional[str] = Query(None, description="服務代碼篩選: hr/erp/mail/ai"),
):
"""取得系統功能清單 (從快取表)"""
query = db.query(SystemFunctionCache).filter(SystemFunctionCache.is_active == True)
if service_code:
query = query.filter(SystemFunctionCache.service_code == service_code)
functions = query.order_by(SystemFunctionCache.service_code, SystemFunctionCache.id).all()
return [
{
"id": f.id,
"service_code": f.service_code,
"function_code": f.function_code,
"function_name": f.function_name,
"function_category": f.function_category,
}
for f in functions
]

View File

@@ -0,0 +1,144 @@
"""
API v1 主路由
"""
from fastapi import APIRouter
from app.api.v1 import (
auth,
tenants,
employees,
departments,
department_members,
roles,
# identities, # Removed: EmployeeIdentity and BusinessUnit models have been deleted
network_drives,
audit_logs,
email_accounts,
permissions,
lifecycle,
personal_service_settings,
emp_onboarding,
system_functions,
)
from app.api.v1.endpoints import installation, installation_phases
api_router = APIRouter()
# 認證
api_router.include_router(
auth.router,
prefix="/auth",
tags=["Authentication"]
)
# 租戶管理 (多租戶核心)
api_router.include_router(
tenants.router,
prefix="/tenants",
tags=["Tenants"]
)
# 員工管理
api_router.include_router(
employees.router,
prefix="/employees",
tags=["Employees"]
)
# 部門管理 (統一樹狀結構,取代原 business-units)
api_router.include_router(
departments.router,
prefix="/departments",
tags=["Departments"]
)
# 部門成員管理 (員工多部門歸屬)
api_router.include_router(
department_members.router,
prefix="/department-members",
tags=["Department Members"]
)
# 角色管理 (RBAC)
api_router.include_router(
roles.router,
prefix="/roles",
tags=["Roles & RBAC"]
)
# 身份管理 (已廢棄 API底層 model 已刪除)
# api_router.include_router(
# identities.router,
# prefix="/identities",
# tags=["Employee Identities (Deprecated)"]
# )
# 網路硬碟管理
api_router.include_router(
network_drives.router,
prefix="/network-drives",
tags=["Network Drives"]
)
# 審計日誌
api_router.include_router(
audit_logs.router,
prefix="/audit-logs",
tags=["Audit Logs"]
)
# 郵件帳號管理
api_router.include_router(
email_accounts.router,
prefix="/email-accounts",
tags=["Email Accounts"]
)
# 系統權限管理
api_router.include_router(
permissions.router,
prefix="/permissions",
tags=["Permissions"]
)
# 員工生命週期管理
api_router.include_router(
lifecycle.router,
prefix="",
tags=["Employee Lifecycle"]
)
# 個人化服務設定管理
api_router.include_router(
personal_service_settings.router,
prefix="/personal-services",
tags=["Personal Service Settings"]
)
# 員工到職/離職流程 (v3.1 多租戶架構)
api_router.include_router(
emp_onboarding.router,
prefix="/emp-lifecycle",
tags=["Employee Onboarding (v3.1)"]
)
# 系統初始化與健康檢查
api_router.include_router(
installation.router,
prefix="/installation",
tags=["Installation & Health Check"]
)
# 系統階段轉換Initialization/Operational/Transition
api_router.include_router(
installation_phases.router,
prefix="/installation",
tags=["System Phase Management"]
)
# 系統功能管理
api_router.include_router(
system_functions.router,
prefix="/system-functions",
tags=["System Functions"]
)

View File

@@ -0,0 +1,303 @@
"""
System Functions API
系統功能明細 CRUD API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.db.session import get_db
from app.models.system_function import SystemFunction
from app.schemas.system_function import (
SystemFunctionCreate,
SystemFunctionUpdate,
SystemFunctionResponse,
SystemFunctionListResponse
)
from app.api.deps import get_pagination_params
from app.schemas.base import PaginationParams
router = APIRouter()
@router.get("", response_model=SystemFunctionListResponse)
def get_system_functions(
function_type: Optional[int] = Query(None, description="功能類型 (1:node, 2:function)"),
upper_function_id: Optional[int] = Query(None, description="上層功能代碼"),
is_mana: Optional[bool] = Query(None, description="系統管理"),
is_active: Optional[bool] = Query(None, description="啟用(預設顯示全部)"),
search: Optional[str] = Query(None, description="搜尋 (code or name)"),
pagination: PaginationParams = Depends(get_pagination_params),
db: Session = Depends(get_db),
):
"""
取得系統功能列表
- 支援分頁
- 支援篩選 (function_type, upper_function_id, is_mana, is_active)
- 支援搜尋 (code or name)
"""
query = db.query(SystemFunction)
# 篩選條件
filters = []
if function_type is not None:
filters.append(SystemFunction.function_type == function_type)
if upper_function_id is not None:
filters.append(SystemFunction.upper_function_id == upper_function_id)
if is_mana is not None:
filters.append(SystemFunction.is_mana == is_mana)
if is_active is not None:
filters.append(SystemFunction.is_active == is_active)
# 搜尋
if search:
filters.append(
or_(
SystemFunction.code.ilike(f"%{search}%"),
SystemFunction.name.ilike(f"%{search}%")
)
)
if filters:
query = query.filter(and_(*filters))
# 排序 (依照 order 排序)
query = query.order_by(SystemFunction.order.asc())
# 計算總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
items = query.offset(offset).limit(pagination.page_size).all()
return SystemFunctionListResponse(
total=total,
items=items,
page=pagination.page,
page_size=pagination.page_size
)
@router.get("/{function_id}", response_model=SystemFunctionResponse)
def get_system_function(
function_id: int,
db: Session = Depends(get_db),
):
"""
取得單一系統功能
"""
function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
return function
@router.post("", response_model=SystemFunctionResponse, status_code=status.HTTP_201_CREATED)
def create_system_function(
function_in: SystemFunctionCreate,
db: Session = Depends(get_db),
):
"""
建立系統功能
驗證規則:
- function_type=1 (node) 時, module_code 不能輸入
- function_type=2 (function) 時, module_code 和 module_functions 為必填
- upper_function_id 必須是 function_type=1 且 is_active=1 的功能, 或 0 (初始層)
"""
# 驗證 upper_function_id
if function_in.upper_function_id > 0:
parent = db.query(SystemFunction).filter(
SystemFunction.id == function_in.upper_function_id,
SystemFunction.function_type == 1,
SystemFunction.is_active == True
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid upper_function_id: {function_in.upper_function_id} "
"(must be function_type=1 and is_active=1)"
)
# 檢查 code 是否重複
existing = db.query(SystemFunction).filter(SystemFunction.code == function_in.code).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"System function code already exists: {function_in.code}"
)
# 建立資料
db_function = SystemFunction(**function_in.model_dump())
db.add(db_function)
db.commit()
db.refresh(db_function)
return db_function
@router.put("/{function_id}", response_model=SystemFunctionResponse)
def update_system_function(
function_id: int,
function_in: SystemFunctionUpdate,
db: Session = Depends(get_db),
):
"""
更新系統功能 (完整更新)
"""
# 查詢現有資料
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not db_function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
# 更新資料
update_data = function_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_function, field, value)
db.commit()
db.refresh(db_function)
return db_function
@router.patch("/{function_id}", response_model=SystemFunctionResponse)
def patch_system_function(
function_id: int,
function_in: SystemFunctionUpdate,
db: Session = Depends(get_db),
):
"""
更新系統功能 (部分更新)
"""
return update_system_function(function_id, function_in, db)
@router.delete("/{function_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_system_function(
function_id: int,
db: Session = Depends(get_db),
):
"""
刪除系統功能 (實際上是軟刪除, 設定 is_active=False)
"""
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not db_function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
# 軟刪除
db_function.is_active = False
db.commit()
return None
@router.delete("/{function_id}/hard", status_code=status.HTTP_204_NO_CONTENT)
def hard_delete_system_function(
function_id: int,
db: Session = Depends(get_db),
):
"""
永久刪除系統功能 (硬刪除)
⚠️ 警告: 此操作無法復原
"""
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not db_function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
# 硬刪除
db.delete(db_function)
db.commit()
return None
@router.get("/menu/tree", response_model=List[dict])
def get_menu_tree(
is_sysmana: bool = Query(False, description="是否為系統管理公司"),
db: Session = Depends(get_db),
):
"""
取得功能列表樹狀結構 (用於前端選單顯示)
根據 is_sysmana 過濾功能:
- is_sysmana=true: 返回所有功能 (包含 is_mana=true 的系統管理功能)
- is_sysmana=false: 只返回 is_mana=false 的一般功能
返回格式:
[
{
"id": 10,
"code": "system_managements",
"name": "系統管理後台",
"function_type": 1,
"order": 100,
"function_icon": "",
"module_code": null,
"module_functions": [],
"children": [
{
"id": 11,
"code": "system_settings",
"name": "系統資料設定",
"function_type": 2,
...
}
]
}
]
"""
# 查詢條件
query = db.query(SystemFunction).filter(SystemFunction.is_active == True)
# 如果不是系統管理公司,過濾掉 is_mana=true 的功能
if not is_sysmana:
query = query.filter(SystemFunction.is_mana == False)
# 排序
functions = query.order_by(SystemFunction.order.asc()).all()
# 建立樹狀結構
def build_tree(parent_id: int = 0) -> List[dict]:
tree = []
for func in functions:
if func.upper_function_id == parent_id:
node = {
"id": func.id,
"code": func.code,
"name": func.name,
"function_type": func.function_type,
"order": func.order,
"function_icon": func.function_icon or "",
"module_code": func.module_code,
"module_functions": func.module_functions or [],
"description": func.description or "",
"children": build_tree(func.id) if func.function_type == 1 else []
}
tree.append(node)
return tree
return build_tree(0)

View File

@@ -0,0 +1,603 @@
"""
租戶管理 API
用於管理多租戶資訊(僅系統管理公司可存取)
"""
from typing import List
from datetime import datetime
import secrets
import string
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from app.api.deps import get_db, require_auth, get_current_tenant
from app.models import Tenant, Employee
from app.schemas.tenant import (
TenantCreateRequest,
TenantCreateResponse,
TenantUpdateRequest,
TenantUpdateResponse,
TenantResponse,
InitializationRequest,
InitializationResponse
)
from app.services.keycloak_admin_client import get_keycloak_admin_client
router = APIRouter()
@router.get("/current", summary="取得當前租戶資訊")
def get_current_tenant_info(
tenant: Tenant = Depends(get_current_tenant),
):
"""
取得當前租戶資訊
根據 JWT Token 的 Realm 自動識別租戶
"""
return {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"name_eng": tenant.name_eng,
"tax_id": tenant.tax_id,
"prefix": tenant.prefix,
"tel": tenant.tel,
"add": tenant.add,
"url": tenant.url,
"keycloak_realm": tenant.keycloak_realm,
"is_sysmana": tenant.is_sysmana,
"plan_id": tenant.plan_id,
"max_users": tenant.max_users,
"storage_quota_gb": tenant.storage_quota_gb,
"status": tenant.status,
"is_active": tenant.is_active,
"edit_by": tenant.edit_by,
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
}
@router.patch("/current", summary="更新當前租戶資訊")
def update_current_tenant_info(
request: TenantUpdateRequest,
db: Session = Depends(get_db),
tenant: Tenant = Depends(get_current_tenant),
):
"""
更新當前租戶的基本資料
僅允許更新以下欄位:
- name: 公司名稱
- name_eng: 公司英文名稱
- tax_id: 統一編號
- tel: 公司電話
- add: 公司地址
- url: 公司網站
注意: 租戶代碼 (code)、前綴 (prefix)、方案等核心欄位不可修改
"""
try:
# 更新欄位
if request.name is not None:
tenant.name = request.name
if request.name_eng is not None:
tenant.name_eng = request.name_eng
if request.tax_id is not None:
tenant.tax_id = request.tax_id
if request.tel is not None:
tenant.tel = request.tel
if request.add is not None:
tenant.add = request.add
if request.url is not None:
tenant.url = request.url
# 更新編輯者
tenant.edit_by = "current_user" # TODO: 從 JWT Token 取得實際用戶名稱
db.commit()
db.refresh(tenant)
return {
"message": "公司資料已成功更新",
"tenant": {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"name_eng": tenant.name_eng,
"tax_id": tenant.tax_id,
"tel": tenant.tel,
"add": tenant.add,
"url": tenant.url,
}
}
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"更新失敗: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新失敗: {str(e)}"
)
@router.get("/", summary="列出所有租戶(僅系統管理公司)")
def list_tenants(
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
列出所有租戶
權限要求: 必須為系統管理公司 (is_sysmana=True)
"""
# 權限檢查
if not current_tenant.is_sysmana:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system management company can access this resource"
)
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return {
"total": len(tenants),
"items": [
{
"id": t.id,
"code": t.code,
"name": t.name,
"name_eng": t.name_eng,
"keycloak_realm": t.keycloak_realm,
"tax_id": t.tax_id,
"prefix": t.prefix,
"domain_set": t.domain_set,
"tel": t.tel,
"add": t.add,
"url": t.url,
"plan_id": t.plan_id,
"max_users": t.max_users,
"storage_quota_gb": t.storage_quota_gb,
"status": t.status,
"is_sysmana": t.is_sysmana,
"is_active": t.is_active,
"is_initialized": t.is_initialized,
"initialized_at": t.initialized_at.isoformat() if t.initialized_at else None,
"initialized_by": t.initialized_by,
"edit_by": t.edit_by,
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
}
for t in tenants
],
}
@router.get("/{tenant_id}", summary="取得指定租戶資訊(僅系統管理公司)")
def get_tenant(
tenant_id: int,
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
取得指定租戶詳細資訊
權限要求: 必須為系統管理公司 (is_sysmana=True)
"""
# 權限檢查
if not current_tenant.is_sysmana:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system management company can access this resource"
)
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
)
return {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"name_eng": tenant.name_eng,
"tax_id": tenant.tax_id,
"prefix": tenant.prefix,
"domain_set": tenant.domains,
"tel": tenant.tel,
"add": tenant.add,
"url": tenant.url,
"keycloak_realm": tenant.keycloak_realm,
"is_sysmana": tenant.is_sysmana,
"plan_id": tenant.plan_id,
"max_users": tenant.max_users,
"storage_quota_gb": tenant.storage_quota_gb,
"status": tenant.status,
"is_active": tenant.is_active,
"is_initialized": tenant.is_initialized,
"initialized_at": tenant.initialized_at,
"initialized_by": tenant.initialized_by,
"created_at": tenant.created_at,
"updated_at": tenant.updated_at,
}
def _generate_temp_password(length: int = 12) -> str:
"""產生臨時密碼"""
chars = string.ascii_letters + string.digits + "!@#$%"
return ''.join(secrets.choice(chars) for _ in range(length))
@router.post("/", response_model=TenantCreateResponse, summary="建立新租戶(僅 Superuser")
def create_tenant(
request: TenantCreateRequest,
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
建立新租戶(含 Keycloak Realm + Tenant Admin 帳號)
權限要求: 必須為系統管理公司 (is_sysmana=True)
流程:
1. 驗證租戶代碼唯一性
2. 建立 Keycloak Realm
3. 在 Keycloak Realm 中建立 Tenant Admin 使用者
4. 建立租戶記錄tenants 表)
5. 建立 Employee 記錄employees 表)
6. 返回租戶資訊與臨時密碼
Returns:
租戶資訊 + Tenant Admin 登入資訊
"""
# ========== 權限檢查 ==========
if not current_tenant.is_sysmana:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system management company can create tenants"
)
# ========== Step 1: 驗證租戶代碼唯一性 ==========
existing_tenant = db.query(Tenant).filter(Tenant.code == request.code).first()
if existing_tenant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Tenant code '{request.code}' already exists"
)
# 產生 Keycloak Realm 名稱 (格式: porscheworld-pwd)
realm_name = f"porscheworld-{request.code.lower()}"
# ========== Step 2: 建立 Keycloak Realm ==========
keycloak_client = get_keycloak_admin_client()
realm_config = keycloak_client.create_realm(
realm_name=realm_name,
display_name=request.name
)
if not realm_config:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Keycloak Realm"
)
try:
# ========== Step 3: 建立 Keycloak Realm Role (tenant-admin) ==========
keycloak_client.create_realm_role(
realm_name=realm_name,
role_name="tenant-admin",
description="租戶管理員 - 可管理公司內所有資源"
)
# ========== Step 4: 建立租戶記錄 ==========
new_tenant = Tenant(
code=request.code,
name=request.name,
name_eng=request.name_eng,
tax_id=request.tax_id,
prefix=request.prefix,
tel=request.tel,
add=request.add,
url=request.url,
keycloak_realm=realm_name,
plan_id=request.plan_id,
max_users=request.max_users,
storage_quota_gb=request.storage_quota_gb,
status="trial",
is_sysmana=False,
is_active=True,
is_initialized=False, # 尚未初始化
edit_by="system"
)
db.add(new_tenant)
db.flush() # 取得 tenant.id
# ========== Step 5: 在 Keycloak 建立 Tenant Admin 使用者 ==========
# 使用提供的臨時密碼或產生新的
temp_password = request.admin_temp_password
# 分割姓名 (假設格式: "陳保時" → firstName="保時", lastName="陳")
name_parts = request.admin_name.split()
if len(name_parts) >= 2:
first_name = " ".join(name_parts[1:])
last_name = name_parts[0]
else:
first_name = request.admin_name
last_name = ""
keycloak_user_id = keycloak_client.create_user(
username=request.admin_username,
email=request.admin_email,
first_name=first_name,
last_name=last_name,
enabled=True,
email_verified=False
)
if not keycloak_user_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Keycloak user"
)
# 設定臨時密碼(首次登入必須變更)
keycloak_client.reset_password(
user_id=keycloak_user_id,
password=temp_password,
temporary=True # 臨時密碼
)
# 將 tenant-admin 角色分配給使用者
role_assigned = keycloak_client.assign_realm_role_to_user(
realm_name=realm_name,
user_id=keycloak_user_id,
role_name="tenant-admin"
)
if not role_assigned:
print(f"⚠️ Warning: Failed to assign tenant-admin role to user {keycloak_user_id}")
# 不中斷流程,但記錄警告
# ========== Step 6: 建立 Employee 記錄 ==========
admin_employee = Employee(
tenant_id=new_tenant.id,
seq_no=1, # 第一號員工
tenant_emp_code=f"{request.prefix}0001",
name=request.admin_name,
name_eng=name_parts[0] if len(name_parts) >= 2 else request.admin_name,
keycloak_username=request.admin_username,
keycloak_user_id=keycloak_user_id,
storage_quota_gb=100, # Admin 預設配額
email_quota_mb=10240, # 10 GB
employment_status="active",
is_active=True,
edit_by="system"
)
db.add(admin_employee)
db.commit()
# ========== Step 7: 返回結果 ==========
return TenantCreateResponse(
message="Tenant created successfully",
tenant={
"id": new_tenant.id,
"code": new_tenant.code,
"name": new_tenant.name,
"keycloak_realm": realm_name,
"status": new_tenant.status,
},
admin_user={
"username": request.admin_username,
"email": request.admin_email,
"keycloak_user_id": keycloak_user_id,
},
keycloak_realm=realm_name,
temporary_password=temp_password
)
except IntegrityError as e:
db.rollback()
# 嘗試清理已建立的 Realm
keycloak_client.delete_realm(realm_name)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database integrity error: {str(e)}"
)
except Exception as e:
db.rollback()
# 嘗試清理已建立的 Realm
keycloak_client.delete_realm(realm_name)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create tenant: {str(e)}"
)
@router.post("/{tenant_id}/initialize", response_model=InitializationResponse, summary="完成租戶初始化(僅 Tenant Admin")
def initialize_tenant(
tenant_id: int,
request: InitializationRequest,
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
完成租戶初始化流程
權限要求:
- 必須為該租戶的成員
- 必須擁有 tenant-admin 角色 (在 Keycloak 驗證)
- 租戶必須尚未初始化 (is_initialized = false)
流程:
1. 驗證權限與初始化狀態
2. 更新公司基本資料
3. 建立部門結構
4. 建立系統角色 (同步到 Keycloak)
5. 儲存預設配額與服務設定
6. 設定 is_initialized = true
7. 記錄審計日誌
Returns:
初始化結果摘要
"""
from app.models import Department, UserRole, AuditLog
# ========== Step 1: 權限檢查 ==========
# 驗證使用者屬於該租戶
if current_tenant.id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only initialize your own tenant"
)
# 取得租戶記錄
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
)
# 防止重複初始化
if tenant.is_initialized:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant has already been initialized. Initialization wizard is locked."
)
# TODO: 驗證使用者擁有 tenant-admin 角色 (從 JWT Token 或 Keycloak API)
# 目前暫時跳過,後續實作 JWT Token 驗證
try:
# ========== Step 2: 更新公司基本資料 ==========
company_info = request.company_info
if "name" in company_info:
tenant.name = company_info["name"]
if "name_eng" in company_info:
tenant.name_eng = company_info["name_eng"]
if "tax_id" in company_info:
tenant.tax_id = company_info["tax_id"]
if "tel" in company_info:
tenant.tel = company_info["tel"]
if "add" in company_info:
tenant.add = company_info["add"]
if "url" in company_info:
tenant.url = company_info["url"]
# ========== Step 3: 建立部門結構 ==========
departments_created = []
for dept_data in request.departments:
new_dept = Department(
tenant_id=tenant_id,
code=dept_data.get("code", dept_data["name"][:10]),
name=dept_data["name"],
name_eng=dept_data.get("name_eng"),
parent_id=dept_data.get("parent_id"),
is_active=True,
edit_by="system"
)
db.add(new_dept)
departments_created.append(dept_data["name"])
db.flush() # 取得部門 ID
# ========== Step 4: 建立系統角色 ==========
keycloak_client = get_keycloak_admin_client()
roles_created = []
for role_data in request.roles:
# 在資料庫建立角色記錄
new_role = UserRole(
tenant_id=tenant_id,
role_code=role_data["code"],
role_name=role_data["name"],
description=role_data.get("description", ""),
is_active=True,
edit_by="system"
)
db.add(new_role)
# 在 Keycloak Realm 建立對應角色
role_created = keycloak_client.create_realm_role(
realm_name=tenant.keycloak_realm,
role_name=role_data["code"],
description=role_data.get("description", role_data["name"])
)
if role_created:
roles_created.append(role_data["name"])
else:
print(f"⚠️ Warning: Failed to create role {role_data['code']} in Keycloak")
# ========== Step 5: 儲存預設配額與服務設定 ==========
# TODO: 實作預設配額儲存邏輯 (需要設計 tenant_settings 表)
# 目前暫時儲存在 tenant 的 JSONB 欄位或獨立表
default_settings = request.default_settings
# 這裡可以儲存到 tenant metadata 或獨立的 settings 表
# ========== Step 6: 設定初始化完成 ==========
tenant.is_initialized = True
tenant.initialized_at = datetime.utcnow()
# TODO: 從 JWT Token 取得 current_user.username
tenant.initialized_by = "admin" # 暫時硬編碼
# ========== Step 7: 記錄審計日誌 ==========
audit_log = AuditLog(
tenant_id=tenant_id,
user_id=None, # TODO: 從 current_user 取得
action="tenant.initialized",
resource_type="tenant",
resource_id=str(tenant_id),
details={
"departments_created": len(departments_created),
"roles_created": len(roles_created),
"department_names": departments_created,
"role_names": roles_created,
"default_settings": default_settings,
},
ip_address=None, # TODO: 從 request 取得
user_agent=None,
)
db.add(audit_log)
# 提交所有變更
db.commit()
# ========== Step 8: 返回結果 ==========
return InitializationResponse(
message="Tenant initialization completed successfully",
summary={
"tenant_id": tenant_id,
"tenant_name": tenant.name,
"departments_created": len(departments_created),
"roles_created": len(roles_created),
"initialized_at": tenant.initialized_at.isoformat(),
"initialized_by": tenant.initialized_by,
}
)
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database integrity error: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Initialization failed: {str(e)}"
)