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:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
222
backend/app/api/deps.py
Normal file
222
backend/app/api/deps.py
Normal 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
|
||||
3
backend/app/api/v1/__init__.py
Normal file
3
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API v1 模組
|
||||
"""
|
||||
209
backend/app/api/v1/audit_logs.py
Normal file
209
backend/app/api/v1/audit_logs.py
Normal 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
362
backend/app/api/v1/auth.py
Normal 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)}"
|
||||
)
|
||||
213
backend/app/api/v1/business_units.py
Normal file
213
backend/app/api/v1/business_units.py
Normal 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]
|
||||
226
backend/app/api/v1/department_members.py
Normal file
226
backend/app/api/v1/department_members.py
Normal 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")
|
||||
373
backend/app/api/v1/departments.py
Normal file
373
backend/app/api/v1/departments.py
Normal 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
|
||||
445
backend/app/api/v1/email_accounts.py
Normal file
445
backend/app/api/v1/email_accounts.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
郵件帳號管理 API
|
||||
符合 WebMail 設計規範 - 員工只能使用 HR Portal 授權的郵件帳號
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.schemas.email_account import (
|
||||
EmailAccountCreate,
|
||||
EmailAccountUpdate,
|
||||
EmailAccountResponse,
|
||||
EmailAccountListItem,
|
||||
EmailAccountQuotaUpdate,
|
||||
)
|
||||
from app.schemas.base import PaginationParams, PaginatedResponse
|
||||
from app.schemas.response import SuccessResponse, MessageResponse
|
||||
from app.api.deps import get_pagination_params
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse)
|
||||
def get_email_accounts(
|
||||
db: Session = Depends(get_db),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
|
||||
is_active: Optional[bool] = Query(None, description="狀態篩選"),
|
||||
search: Optional[str] = Query(None, description="搜尋郵件地址"),
|
||||
):
|
||||
"""
|
||||
獲取郵件帳號列表
|
||||
|
||||
支援:
|
||||
- 分頁
|
||||
- 員工篩選
|
||||
- 狀態篩選
|
||||
- 郵件地址搜尋
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(EmailAccount).filter(EmailAccount.tenant_id == tenant_id)
|
||||
|
||||
# 員工篩選
|
||||
if employee_id:
|
||||
query = query.filter(EmailAccount.employee_id == employee_id)
|
||||
|
||||
# 狀態篩選
|
||||
if is_active is not None:
|
||||
query = query.filter(EmailAccount.is_active == is_active)
|
||||
|
||||
# 搜尋
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(EmailAccount.email_address.ilike(search_pattern))
|
||||
|
||||
# 總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
email_accounts = (
|
||||
query.options(joinedload(EmailAccount.employee))
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 計算總頁數
|
||||
total_pages = (total + pagination.page_size - 1) // pagination.page_size
|
||||
|
||||
# 組裝回應資料
|
||||
items = []
|
||||
for account in email_accounts:
|
||||
item = EmailAccountListItem.model_validate(account)
|
||||
item.employee_name = account.employee.legal_name if account.employee else None
|
||||
item.employee_number = account.employee.employee_id if account.employee else None
|
||||
items.append(item)
|
||||
|
||||
return PaginatedResponse(
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
total_pages=total_pages,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{email_account_id}", response_model=EmailAccountResponse)
|
||||
def get_email_account(
|
||||
email_account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取郵件帳號詳情
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = (
|
||||
db.query(EmailAccount)
|
||||
.options(joinedload(EmailAccount.employee))
|
||||
.filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = account.employee.legal_name if account.employee else None
|
||||
response.employee_number = account.employee.employee_id if account.employee else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=EmailAccountResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_email_account(
|
||||
account_data: EmailAccountCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建郵件帳號
|
||||
|
||||
注意:
|
||||
- 郵件地址必須唯一
|
||||
- 員工必須存在
|
||||
- 配額範圍: 1GB - 100GB
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == account_data.employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {account_data.employee_id} not found",
|
||||
)
|
||||
|
||||
# 檢查郵件地址是否已存在
|
||||
existing = db.query(EmailAccount).filter(
|
||||
EmailAccount.email_address == account_data.email_address
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Email address '{account_data.email_address}' already exists",
|
||||
)
|
||||
|
||||
# 創建郵件帳號
|
||||
account = EmailAccount(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=account_data.employee_id,
|
||||
email_address=account_data.email_address,
|
||||
quota_mb=account_data.quota_mb,
|
||||
forward_to=account_data.forward_to,
|
||||
auto_reply=account_data.auto_reply,
|
||||
is_active=account_data.is_active,
|
||||
)
|
||||
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="create_email_account",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"employee_id": account.employee_id,
|
||||
"quota_mb": account.quota_mb,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{email_account_id}", response_model=EmailAccountResponse)
|
||||
def update_email_account(
|
||||
email_account_id: int,
|
||||
account_data: EmailAccountUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新郵件帳號
|
||||
|
||||
可更新:
|
||||
- 配額
|
||||
- 轉寄地址
|
||||
- 自動回覆
|
||||
- 啟用狀態
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = (
|
||||
db.query(EmailAccount)
|
||||
.options(joinedload(EmailAccount.employee))
|
||||
.filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
# 記錄變更前的值
|
||||
changes = {}
|
||||
|
||||
# 更新欄位
|
||||
update_data = account_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
old_value = getattr(account, field)
|
||||
if old_value != value:
|
||||
changes[field] = {"from": old_value, "to": value}
|
||||
setattr(account, field, value)
|
||||
|
||||
if changes:
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="update_email_account",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"changes": changes,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = account.employee.legal_name if account.employee else None
|
||||
response.employee_number = account.employee.employee_id if account.employee else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/{email_account_id}/quota", response_model=EmailAccountResponse)
|
||||
def update_email_quota(
|
||||
email_account_id: int,
|
||||
quota_data: EmailAccountQuotaUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新郵件配額
|
||||
|
||||
快速更新配額的端點
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = (
|
||||
db.query(EmailAccount)
|
||||
.options(joinedload(EmailAccount.employee))
|
||||
.filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
old_quota = account.quota_mb
|
||||
account.quota_mb = quota_data.quota_mb
|
||||
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="update_email_quota",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"old_quota_mb": old_quota,
|
||||
"new_quota_mb": quota_data.quota_mb,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = EmailAccountResponse.model_validate(account)
|
||||
response.employee_name = account.employee.legal_name if account.employee else None
|
||||
response.employee_number = account.employee.employee_id if account.employee else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{email_account_id}", response_model=MessageResponse)
|
||||
def delete_email_account(
|
||||
email_account_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刪除郵件帳號
|
||||
|
||||
注意:
|
||||
- 軟刪除: 設為停用
|
||||
- 需要記錄審計日誌
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
account = db.query(EmailAccount).filter(
|
||||
EmailAccount.id == email_account_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Email account with id {email_account_id} not found",
|
||||
)
|
||||
|
||||
# 軟刪除: 設為停用
|
||||
account.is_active = False
|
||||
db.commit()
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="delete_email_account",
|
||||
resource_type="email_account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"email_address": account.email_address,
|
||||
"employee_id": account.employee_id,
|
||||
},
|
||||
)
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Email account {account.email_address} has been deactivated"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}/email-accounts")
|
||||
def get_employee_email_accounts(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
取得員工授權的郵件帳號列表
|
||||
|
||||
符合 WebMail 設計規範 (HR Portal設計文件 §2):
|
||||
- 員工不可自行新增郵件帳號
|
||||
- 只能使用 HR Portal 授予的帳號
|
||||
- 支援多帳號切換 (ISO 帳號管理流程)
|
||||
|
||||
回傳格式:
|
||||
{
|
||||
"user_id": "porsche.chen",
|
||||
"email_accounts": [
|
||||
{
|
||||
"email": "porsche.chen@porscheworld.tw",
|
||||
"quota_mb": 5120,
|
||||
"status": "active",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found",
|
||||
)
|
||||
|
||||
# 查詢員工的所有啟用郵件帳號
|
||||
accounts = (
|
||||
db.query(EmailAccount)
|
||||
.filter(
|
||||
EmailAccount.employee_id == employee_id,
|
||||
EmailAccount.tenant_id == tenant_id,
|
||||
EmailAccount.is_active == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 組裝符合 WebMail 設計規範的回應格式
|
||||
email_accounts = []
|
||||
for account in accounts:
|
||||
email_accounts.append({
|
||||
"email": account.email_address,
|
||||
"quota_mb": account.quota_mb,
|
||||
"status": "active" if account.is_active else "inactive",
|
||||
"forward_to": account.forward_to,
|
||||
"auto_reply": account.auto_reply,
|
||||
})
|
||||
|
||||
return {
|
||||
"user_id": employee.username_base,
|
||||
"email_accounts": email_accounts,
|
||||
}
|
||||
468
backend/app/api/v1/emp_onboarding.py
Normal file
468
backend/app/api/v1/emp_onboarding.py
Normal 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,
|
||||
}
|
||||
381
backend/app/api/v1/employees.py
Normal file
381
backend/app/api/v1/employees.py
Normal 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 []
|
||||
937
backend/app/api/v1/endpoints/installation.py
Normal file
937
backend/app/api/v1/endpoints/installation.py
Normal 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} 的配置記錄"}
|
||||
|
||||
|
||||
# ==================== 系統階段轉換 ====================
|
||||
|
||||
290
backend/app/api/v1/endpoints/installation_phases.py
Normal file
290
backend/app/api/v1/endpoints/installation_phases.py
Normal 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)}"
|
||||
)
|
||||
364
backend/app/api/v1/identities.py
Normal file
364
backend/app/api/v1/identities.py
Normal 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
|
||||
176
backend/app/api/v1/lifecycle.py
Normal file
176
backend/app/api/v1/lifecycle.py
Normal 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: 是否建立雲端硬碟帳號 (預設: True,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} 不存在"
|
||||
)
|
||||
|
||||
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: 是否停用雲端硬碟帳號 (預設: True,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} 不存在"
|
||||
)
|
||||
|
||||
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 尚未上線",
|
||||
},
|
||||
},
|
||||
}
|
||||
262
backend/app/api/v1/network_drives.py
Normal file
262
backend/app/api/v1/network_drives.py
Normal 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
|
||||
542
backend/app/api/v1/permissions.py
Normal file
542
backend/app/api/v1/permissions.py
Normal 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
|
||||
|
||||
|
||||
326
backend/app/api/v1/personal_service_settings.py
Normal file
326
backend/app/api/v1/personal_service_settings.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
個人化服務設定 API
|
||||
記錄員工啟用的個人化服務(SSO, Email, Calendar, Drive, Office)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
|
||||
from app.models.personal_service import PersonalService
|
||||
from app.schemas.response import MessageResponse
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
def get_current_user_id() -> str:
|
||||
"""取得當前使用者 ID (暫時寫死, 未來從 JWT token sub 取得)"""
|
||||
return "system-admin"
|
||||
|
||||
|
||||
@router.get("/users/{keycloak_user_id}/services")
|
||||
def get_user_services(
|
||||
keycloak_user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得使用者已啟用的服務列表
|
||||
|
||||
Args:
|
||||
keycloak_user_id: Keycloak User UUID
|
||||
include_inactive: 是否包含已停用的服務
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
query = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id
|
||||
)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(
|
||||
EmpPersonalServiceSetting.is_active == True,
|
||||
EmpPersonalServiceSetting.disabled_at == None
|
||||
)
|
||||
|
||||
settings = query.all()
|
||||
|
||||
result = []
|
||||
for setting in settings:
|
||||
service = setting.service
|
||||
result.append({
|
||||
"id": setting.id,
|
||||
"service_id": setting.service_id,
|
||||
"service_name": service.service_name if service else None,
|
||||
"service_code": service.service_code if service else None,
|
||||
"quota_gb": setting.quota_gb,
|
||||
"quota_mb": setting.quota_mb,
|
||||
"enabled_at": setting.enabled_at,
|
||||
"enabled_by": setting.enabled_by,
|
||||
"disabled_at": setting.disabled_at,
|
||||
"disabled_by": setting.disabled_by,
|
||||
"is_active": setting.is_active,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/users/{keycloak_user_id}/services", status_code=status.HTTP_201_CREATED)
|
||||
def enable_service_for_user(
|
||||
keycloak_user_id: str,
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
為使用者啟用個人化服務
|
||||
|
||||
Body:
|
||||
{
|
||||
"service_id": 4, // 服務 ID (必填)
|
||||
"quota_gb": 20, // 儲存配額 (Drive 服務用)
|
||||
"quota_mb": 5120 // 郵件配額 (Email 服務用)
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
service_id = data.get("service_id")
|
||||
quota_gb = data.get("quota_gb")
|
||||
quota_mb = data.get("quota_mb")
|
||||
|
||||
if not service_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="service_id is required"
|
||||
)
|
||||
|
||||
# 檢查服務是否存在
|
||||
service = db.query(PersonalService).filter(
|
||||
PersonalService.id == service_id,
|
||||
PersonalService.is_active == True
|
||||
).first()
|
||||
|
||||
if not service:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service with id {service_id} not found or inactive"
|
||||
)
|
||||
|
||||
# 檢查是否已經啟用
|
||||
existing = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Service {service.service_name} already enabled for this user"
|
||||
)
|
||||
|
||||
# 建立服務設定
|
||||
setting = EmpPersonalServiceSetting(
|
||||
tenant_id=tenant_id,
|
||||
tenant_keycloak_user_id=keycloak_user_id,
|
||||
service_id=service_id,
|
||||
quota_gb=quota_gb,
|
||||
quota_mb=quota_mb,
|
||||
enabled_at=datetime.utcnow(),
|
||||
enabled_by=current_user,
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
# 審計日誌
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="enable_service",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=setting.id,
|
||||
details=f"Enabled {service.service_name} for user {keycloak_user_id}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"id": setting.id,
|
||||
"service_id": setting.service_id,
|
||||
"service_name": service.service_name,
|
||||
"enabled_at": setting.enabled_at,
|
||||
"quota_gb": setting.quota_gb,
|
||||
"quota_mb": setting.quota_mb,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/users/{keycloak_user_id}/services/{service_id}")
|
||||
def disable_service_for_user(
|
||||
keycloak_user_id: str,
|
||||
service_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
停用使用者的個人化服務(軟刪除)
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
# 查詢啟用中的服務設定
|
||||
setting = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service_id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Service setting not found or already disabled"
|
||||
)
|
||||
|
||||
# 軟刪除
|
||||
setting.is_active = False
|
||||
setting.disabled_at = datetime.utcnow()
|
||||
setting.disabled_by = current_user
|
||||
setting.edit_by = current_user
|
||||
|
||||
db.commit()
|
||||
|
||||
# 審計日誌
|
||||
service = setting.service
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="disable_service",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=setting.id,
|
||||
details=f"Disabled {service.service_name if service else service_id} for user {keycloak_user_id}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return MessageResponse(message="Service disabled successfully")
|
||||
|
||||
|
||||
@router.post("/users/{keycloak_user_id}/services/batch-enable", status_code=status.HTTP_201_CREATED)
|
||||
def batch_enable_services(
|
||||
keycloak_user_id: str,
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批次啟用所有個人化服務(員工到職時使用)
|
||||
|
||||
Body:
|
||||
{
|
||||
"storage_quota_gb": 20,
|
||||
"email_quota_mb": 5120
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
current_user = get_current_user_id()
|
||||
|
||||
storage_quota_gb = data.get("storage_quota_gb", 20)
|
||||
email_quota_mb = data.get("email_quota_mb", 5120)
|
||||
|
||||
# 取得所有啟用的服務
|
||||
all_services = db.query(PersonalService).filter(
|
||||
PersonalService.is_active == True
|
||||
).all()
|
||||
|
||||
enabled_services = []
|
||||
|
||||
for service in all_services:
|
||||
# 檢查是否已啟用
|
||||
existing = db.query(EmpPersonalServiceSetting).filter(
|
||||
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
||||
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
||||
EmpPersonalServiceSetting.service_id == service.id,
|
||||
EmpPersonalServiceSetting.is_active == True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
continue # 已啟用,跳過
|
||||
|
||||
# 根據服務類型設定配額
|
||||
quota_gb = storage_quota_gb if service.service_code == "Drive" else None
|
||||
quota_mb = email_quota_mb if service.service_code == "Email" else None
|
||||
|
||||
setting = EmpPersonalServiceSetting(
|
||||
tenant_id=tenant_id,
|
||||
tenant_keycloak_user_id=keycloak_user_id,
|
||||
service_id=service.id,
|
||||
quota_gb=quota_gb,
|
||||
quota_mb=quota_mb,
|
||||
enabled_at=datetime.utcnow(),
|
||||
enabled_by=current_user,
|
||||
is_active=True,
|
||||
edit_by=current_user
|
||||
)
|
||||
|
||||
db.add(setting)
|
||||
enabled_services.append(service.service_name)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 審計日誌
|
||||
audit_service.log_action(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user,
|
||||
action="batch_enable_services",
|
||||
resource_type="emp_personal_service_setting",
|
||||
resource_id=None,
|
||||
details=f"Batch enabled {len(enabled_services)} services for user {keycloak_user_id}: {', '.join(enabled_services)}",
|
||||
ip_address=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled_count": len(enabled_services),
|
||||
"services": enabled_services
|
||||
}
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
def get_all_services(
|
||||
db: Session = Depends(get_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得所有可用的個人化服務列表
|
||||
"""
|
||||
query = db.query(PersonalService)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(PersonalService.is_active == True)
|
||||
|
||||
services = query.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"service_name": s.service_name,
|
||||
"service_code": s.service_code,
|
||||
"is_active": s.is_active,
|
||||
}
|
||||
for s in services
|
||||
]
|
||||
389
backend/app/api/v1/roles.py
Normal file
389
backend/app/api/v1/roles.py
Normal 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
|
||||
]
|
||||
144
backend/app/api/v1/router.py
Normal file
144
backend/app/api/v1/router.py
Normal 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"]
|
||||
)
|
||||
303
backend/app/api/v1/system_functions.py
Normal file
303
backend/app/api/v1/system_functions.py
Normal 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)
|
||||
603
backend/app/api/v1/tenants.py
Normal file
603
backend/app/api/v1/tenants.py
Normal 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)}"
|
||||
)
|
||||
4
backend/app/batch/__init__.py
Normal file
4
backend/app/batch/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
批次作業模組
|
||||
包含所有定時排程的批次處理任務
|
||||
"""
|
||||
160
backend/app/batch/archive_audit_logs.py
Normal file
160
backend/app/batch/archive_audit_logs.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
審計日誌歸檔批次 (5.3)
|
||||
執行時間: 每月 1 日 01:00
|
||||
批次名稱: archive_audit_logs
|
||||
|
||||
將 90 天前的審計日誌匯出為 CSV,並從主資料庫刪除
|
||||
歸檔目錄: /mnt/nas/working/audit_logs/
|
||||
"""
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.batch.base import log_batch_execution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ARCHIVE_DAYS = 90 # 保留最近 90 天,超過的歸檔
|
||||
ARCHIVE_BASE_DIR = "/mnt/nas/working/audit_logs"
|
||||
|
||||
|
||||
def _get_archive_dir() -> str:
|
||||
"""取得歸檔目錄,不存在時建立"""
|
||||
os.makedirs(ARCHIVE_BASE_DIR, exist_ok=True)
|
||||
return ARCHIVE_BASE_DIR
|
||||
|
||||
|
||||
def run_archive_audit_logs(dry_run: bool = False) -> dict:
|
||||
"""
|
||||
執行審計日誌歸檔批次
|
||||
|
||||
Args:
|
||||
dry_run: True 時只統計不實際刪除
|
||||
|
||||
Returns:
|
||||
執行結果摘要
|
||||
"""
|
||||
started_at = datetime.utcnow()
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=ARCHIVE_DAYS)
|
||||
|
||||
logger.info(f"=== 開始審計日誌歸檔批次 === 截止日期: {cutoff_date.strftime('%Y-%m-%d')}")
|
||||
if dry_run:
|
||||
logger.info("[DRY RUN] 不會實際刪除資料")
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
# 1. 查詢超過 90 天的日誌
|
||||
old_logs = db.query(AuditLog).filter(
|
||||
AuditLog.performed_at < cutoff_date
|
||||
).order_by(AuditLog.performed_at).all()
|
||||
|
||||
total_count = len(old_logs)
|
||||
logger.info(f"找到 {total_count} 筆待歸檔日誌")
|
||||
|
||||
if total_count == 0:
|
||||
message = f"無需歸檔 (截止日期 {cutoff_date.strftime('%Y-%m-%d')} 前無記錄)"
|
||||
log_batch_execution(
|
||||
batch_name="archive_audit_logs",
|
||||
status="success",
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "success", "archived": 0, "message": message}
|
||||
|
||||
# 2. 匯出到 CSV
|
||||
archive_month = cutoff_date.strftime("%Y%m")
|
||||
archive_dir = _get_archive_dir()
|
||||
csv_path = os.path.join(archive_dir, f"archive_{archive_month}.csv")
|
||||
|
||||
fieldnames = [
|
||||
"id", "action", "resource_type", "resource_id",
|
||||
"performed_by", "ip_address",
|
||||
"details", "performed_at"
|
||||
]
|
||||
|
||||
logger.info(f"匯出至: {csv_path}")
|
||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for log in old_logs:
|
||||
writer.writerow({
|
||||
"id": log.id,
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
"performed_by": getattr(log, "performed_by", ""),
|
||||
"ip_address": getattr(log, "ip_address", ""),
|
||||
"details": str(getattr(log, "details", "")),
|
||||
"performed_at": str(log.performed_at),
|
||||
})
|
||||
|
||||
logger.info(f"已匯出 {total_count} 筆至 {csv_path}")
|
||||
|
||||
# 3. 刪除舊日誌 (非 dry_run 才執行)
|
||||
deleted_count = 0
|
||||
if not dry_run:
|
||||
for log in old_logs:
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
deleted_count = total_count
|
||||
logger.info(f"已刪除 {deleted_count} 筆舊日誌")
|
||||
else:
|
||||
logger.info(f"[DRY RUN] 將刪除 {total_count} 筆 (未實際執行)")
|
||||
|
||||
# 4. 記錄批次執行日誌
|
||||
finished_at = datetime.utcnow()
|
||||
message = (
|
||||
f"歸檔 {total_count} 筆到 {csv_path}"
|
||||
+ (f"; 已刪除 {deleted_count} 筆" if not dry_run else " (DRY RUN)")
|
||||
)
|
||||
log_batch_execution(
|
||||
batch_name="archive_audit_logs",
|
||||
status="success",
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
logger.info(f"=== 審計日誌歸檔批次完成 === {message}")
|
||||
return {
|
||||
"status": "success",
|
||||
"archived": total_count,
|
||||
"deleted": deleted_count,
|
||||
"csv_path": csv_path,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"審計日誌歸檔批次失敗: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
log_batch_execution(
|
||||
batch_name="archive_audit_logs",
|
||||
status="failed",
|
||||
message=error_msg,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import argparse
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true", help="只統計不實際刪除")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run_archive_audit_logs(dry_run=args.dry_run)
|
||||
print(f"執行結果: {result}")
|
||||
59
backend/app/batch/base.py
Normal file
59
backend/app/batch/base.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
批次作業基礎工具
|
||||
提供 log_batch_execution 等共用函式
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_batch_execution(
|
||||
batch_name: str,
|
||||
status: str,
|
||||
message: Optional[str] = None,
|
||||
started_at: Optional[datetime] = None,
|
||||
finished_at: Optional[datetime] = None,
|
||||
) -> None:
|
||||
"""
|
||||
記錄批次執行日誌到資料庫
|
||||
|
||||
Args:
|
||||
batch_name: 批次名稱
|
||||
status: 執行狀態 (success/failed/warning)
|
||||
message: 執行訊息
|
||||
started_at: 開始時間 (若未提供則使用 finished_at)
|
||||
finished_at: 完成時間 (若未提供則使用現在)
|
||||
"""
|
||||
from app.db.session import get_db
|
||||
from app.models.batch_log import BatchLog
|
||||
|
||||
now = datetime.utcnow()
|
||||
finished = finished_at or now
|
||||
started = started_at or finished
|
||||
|
||||
duration = None
|
||||
if started and finished:
|
||||
duration = int((finished - started).total_seconds())
|
||||
|
||||
try:
|
||||
db = next(get_db())
|
||||
log_entry = BatchLog(
|
||||
batch_name=batch_name,
|
||||
status=status,
|
||||
message=message,
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
duration_seconds=duration,
|
||||
)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
logger.info(f"[{batch_name}] 批次執行記錄已寫入: {status}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{batch_name}] 寫入批次日誌失敗: {e}")
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
152
backend/app/batch/daily_quota_check.py
Normal file
152
backend/app/batch/daily_quota_check.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
每日配額檢查批次 (5.1)
|
||||
執行時間: 每日 02:00
|
||||
批次名稱: daily_quota_check
|
||||
|
||||
檢查郵件和雲端硬碟配額使用情況,超過 80% 發送告警
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.batch.base import log_batch_execution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUOTA_ALERT_THRESHOLD = 0.8 # 超過 80% 發送告警
|
||||
ALERT_EMAIL = "admin@porscheworld.tw"
|
||||
|
||||
|
||||
def _send_alert_email(to: str, subject: str, body: str) -> bool:
|
||||
"""
|
||||
發送告警郵件
|
||||
|
||||
目前使用 SMTP 直送,未來可整合 Mailserver
|
||||
"""
|
||||
try:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from app.core.config import settings
|
||||
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.MAIL_ADMIN_USER
|
||||
msg["To"] = to
|
||||
|
||||
with smtplib.SMTP(settings.MAIL_SERVER, settings.MAIL_PORT) as smtp:
|
||||
if settings.MAIL_USE_TLS:
|
||||
smtp.starttls()
|
||||
smtp.login(settings.MAIL_ADMIN_USER, settings.MAIL_ADMIN_PASSWORD)
|
||||
smtp.send_message(msg)
|
||||
|
||||
logger.info(f"告警郵件已發送至 {to}: {subject}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"發送告警郵件失敗: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_daily_quota_check() -> dict:
|
||||
"""
|
||||
執行每日配額檢查批次
|
||||
|
||||
Returns:
|
||||
執行結果摘要
|
||||
"""
|
||||
started_at = datetime.utcnow()
|
||||
alerts_sent = 0
|
||||
errors = []
|
||||
summary = {
|
||||
"email_checked": 0,
|
||||
"email_alerts": 0,
|
||||
"drive_checked": 0,
|
||||
"drive_alerts": 0,
|
||||
}
|
||||
|
||||
logger.info("=== 開始每日配額檢查批次 ===")
|
||||
|
||||
# 取得資料庫 Session
|
||||
from app.db.session import get_db
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.models.network_drive import NetworkDrive
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
# 1. 檢查郵件配額
|
||||
logger.info("檢查郵件配額使用情況...")
|
||||
email_accounts = db.query(EmailAccount).filter(
|
||||
EmailAccount.is_active == True
|
||||
).all()
|
||||
|
||||
for account in email_accounts:
|
||||
summary["email_checked"] += 1
|
||||
# 目前郵件 Mailserver API 未整合,跳過實際配額查詢
|
||||
# TODO: 整合 Mailserver API 後取得實際使用量
|
||||
# usage_mb = mailserver_service.get_usage(account.email_address)
|
||||
# if usage_mb and usage_mb / account.quota_mb > QUOTA_ALERT_THRESHOLD:
|
||||
# _send_alert_email(...)
|
||||
pass
|
||||
|
||||
logger.info(f"郵件帳號檢查完成: {summary['email_checked']} 個帳號")
|
||||
|
||||
# 2. 檢查雲端硬碟配額 (Drive Service API)
|
||||
logger.info("檢查雲端硬碟配額使用情況...")
|
||||
network_drives = db.query(NetworkDrive).filter(
|
||||
NetworkDrive.is_active == True
|
||||
).all()
|
||||
|
||||
from app.services.drive_service import get_drive_service_client
|
||||
drive_client = get_drive_service_client()
|
||||
|
||||
for drive in network_drives:
|
||||
summary["drive_checked"] += 1
|
||||
try:
|
||||
# 查詢配額使用量 (Drive Service 未上線時會回傳 None)
|
||||
# 注意: drive.id 是資料庫 ID,需要 drive_user_id
|
||||
# 目前跳過實際查詢,等 Drive Service 上線後補充
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"查詢 {drive.drive_name} 配額失敗: {e}")
|
||||
|
||||
logger.info(f"雲端硬碟檢查完成: {summary['drive_checked']} 個帳號")
|
||||
|
||||
# 3. 記錄批次執行日誌
|
||||
finished_at = datetime.utcnow()
|
||||
message = (
|
||||
f"郵件帳號: {summary['email_checked']} 個, 告警: {summary['email_alerts']} 個; "
|
||||
f"雲端硬碟: {summary['drive_checked']} 個, 告警: {summary['drive_alerts']} 個"
|
||||
)
|
||||
log_batch_execution(
|
||||
batch_name="daily_quota_check",
|
||||
status="success",
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
logger.info(f"=== 每日配額檢查批次完成 === {message}")
|
||||
return {"status": "success", "summary": summary}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"每日配額檢查批次失敗: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_batch_execution(
|
||||
batch_name="daily_quota_check",
|
||||
status="failed",
|
||||
message=error_msg,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 允許直接執行此批次
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
result = run_daily_quota_check()
|
||||
print(f"執行結果: {result}")
|
||||
103
backend/app/batch/scheduler.py
Normal file
103
backend/app/batch/scheduler.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
批次作業排程器 (5.4)
|
||||
使用 schedule 套件管理所有批次排程
|
||||
|
||||
排程清單:
|
||||
- 每日 00:00 - auto_terminate_employees (未來實作)
|
||||
- 每日 02:00 - daily_quota_check
|
||||
- 每日 03:00 - sync_keycloak_users
|
||||
- 每月 1 日 01:00 - archive_audit_logs
|
||||
|
||||
啟動方式:
|
||||
python -m app.batch.scheduler
|
||||
"""
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
def _run_daily_quota_check():
|
||||
logger.info("觸發: 每日配額檢查批次")
|
||||
try:
|
||||
from app.batch.daily_quota_check import run_daily_quota_check
|
||||
result = run_daily_quota_check()
|
||||
logger.info(f"每日配額檢查批次完成: {result.get('status')}")
|
||||
except Exception as e:
|
||||
logger.error(f"每日配額檢查批次異常: {e}")
|
||||
|
||||
|
||||
def _run_sync_keycloak_users():
|
||||
logger.info("觸發: Keycloak 同步批次")
|
||||
try:
|
||||
from app.batch.sync_keycloak_users import run_sync_keycloak_users
|
||||
result = run_sync_keycloak_users()
|
||||
logger.info(f"Keycloak 同步批次完成: {result.get('status')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Keycloak 同步批次異常: {e}")
|
||||
|
||||
|
||||
def _run_archive_audit_logs():
|
||||
"""只在每月 1 日執行"""
|
||||
if datetime.now().day != 1:
|
||||
return
|
||||
logger.info("觸發: 審計日誌歸檔批次 (每月 1 日)")
|
||||
try:
|
||||
from app.batch.archive_audit_logs import run_archive_audit_logs
|
||||
result = run_archive_audit_logs()
|
||||
logger.info(f"審計日誌歸檔批次完成: {result.get('status')}")
|
||||
except Exception as e:
|
||||
logger.error(f"審計日誌歸檔批次異常: {e}")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""啟動排程器"""
|
||||
try:
|
||||
import schedule
|
||||
except ImportError:
|
||||
logger.error("缺少 schedule 套件,請執行: pip install schedule")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("=== HR Portal 批次排程器啟動 ===")
|
||||
|
||||
# 每日 02:00 - 配額檢查
|
||||
schedule.every().day.at("02:00").do(_run_daily_quota_check)
|
||||
|
||||
# 每日 03:00 - Keycloak 同步
|
||||
schedule.every().day.at("03:00").do(_run_sync_keycloak_users)
|
||||
|
||||
# 每日 01:00 - 審計日誌歸檔 (函式內部判斷是否為每月 1 日)
|
||||
schedule.every().day.at("01:00").do(_run_archive_audit_logs)
|
||||
|
||||
logger.info("排程設定完成:")
|
||||
logger.info(" 02:00 - 每日配額檢查")
|
||||
logger.info(" 03:00 - Keycloak 同步")
|
||||
logger.info(" 01:00 - 審計日誌歸檔 (每月 1 日)")
|
||||
|
||||
# 處理 SIGTERM (Docker 停止信號)
|
||||
def handle_sigterm(signum, frame):
|
||||
logger.info("收到停止信號,排程器正在關閉...")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||
|
||||
logger.info("排程器運行中,等待任務觸發...")
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(60) # 每分鐘檢查一次
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_setup_logging()
|
||||
start_scheduler()
|
||||
146
backend/app/batch/sync_keycloak_users.py
Normal file
146
backend/app/batch/sync_keycloak_users.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Keycloak 同步批次 (5.2)
|
||||
執行時間: 每日 03:00
|
||||
批次名稱: sync_keycloak_users
|
||||
|
||||
同步 Keycloak 使用者狀態到 HR Portal
|
||||
以 HR Portal 為準 (Single Source of Truth)
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.batch.base import log_batch_execution
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_sync_keycloak_users() -> dict:
|
||||
"""
|
||||
執行 Keycloak 同步批次
|
||||
|
||||
以 HR Portal 員工狀態為準,同步到 Keycloak:
|
||||
- active → Keycloak enabled = True
|
||||
- terminated/on_leave → Keycloak enabled = False
|
||||
|
||||
Returns:
|
||||
執行結果摘要
|
||||
"""
|
||||
started_at = datetime.utcnow()
|
||||
summary = {
|
||||
"total_checked": 0,
|
||||
"synced": 0,
|
||||
"not_found_in_keycloak": 0,
|
||||
"no_keycloak_id": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
issues = []
|
||||
|
||||
logger.info("=== 開始 Keycloak 同步批次 ===")
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.services.keycloak_admin_client import get_keycloak_admin_client
|
||||
|
||||
db = next(get_db())
|
||||
try:
|
||||
# 1. 取得所有員工
|
||||
employees = db.query(Employee).all()
|
||||
keycloak_client = get_keycloak_admin_client()
|
||||
|
||||
logger.info(f"共 {len(employees)} 位員工待檢查")
|
||||
|
||||
for emp in employees:
|
||||
summary["total_checked"] += 1
|
||||
|
||||
# 跳過沒有 Keycloak ID 的員工 (尚未執行到職流程)
|
||||
# 以 username_base 查詢 Keycloak
|
||||
username = emp.username_base
|
||||
if not username:
|
||||
summary["no_keycloak_id"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# 2. 查詢 Keycloak 使用者
|
||||
kc_user = keycloak_client.get_user_by_username(username)
|
||||
|
||||
if not kc_user:
|
||||
# Keycloak 使用者不存在,可能尚未建立
|
||||
summary["not_found_in_keycloak"] += 1
|
||||
logger.debug(f"員工 {emp.employee_id} ({username}) 在 Keycloak 中不存在,跳過")
|
||||
continue
|
||||
|
||||
kc_user_id = kc_user.get("id")
|
||||
kc_enabled = kc_user.get("enabled", False)
|
||||
|
||||
# 3. 判斷應有的 enabled 狀態
|
||||
should_be_enabled = (emp.status == "active")
|
||||
|
||||
# 4. 狀態不一致時,以 HR Portal 為準同步到 Keycloak
|
||||
if kc_enabled != should_be_enabled:
|
||||
success = keycloak_client.update_user(
|
||||
kc_user_id, {"enabled": should_be_enabled}
|
||||
)
|
||||
if success:
|
||||
summary["synced"] += 1
|
||||
logger.info(
|
||||
f"✓ 同步 {emp.employee_id} ({username}): "
|
||||
f"Keycloak enabled {kc_enabled} → {should_be_enabled} "
|
||||
f"(HR 狀態: {emp.status})"
|
||||
)
|
||||
else:
|
||||
summary["errors"] += 1
|
||||
issues.append(f"{emp.employee_id}: 同步失敗")
|
||||
logger.warning(f"✗ 同步 {emp.employee_id} ({username}) 失敗")
|
||||
|
||||
except Exception as e:
|
||||
summary["errors"] += 1
|
||||
issues.append(f"{emp.employee_id}: {str(e)}")
|
||||
logger.error(f"處理員工 {emp.employee_id} 時發生錯誤: {e}")
|
||||
|
||||
# 5. 記錄批次執行日誌
|
||||
finished_at = datetime.utcnow()
|
||||
message = (
|
||||
f"檢查: {summary['total_checked']}, "
|
||||
f"同步: {summary['synced']}, "
|
||||
f"Keycloak 無帳號: {summary['not_found_in_keycloak']}, "
|
||||
f"錯誤: {summary['errors']}"
|
||||
)
|
||||
if issues:
|
||||
message += f"\n問題清單: {'; '.join(issues[:10])}"
|
||||
if len(issues) > 10:
|
||||
message += f" ... 共 {len(issues)} 個問題"
|
||||
|
||||
status = "failed" if summary["errors"] > 0 else "success"
|
||||
log_batch_execution(
|
||||
batch_name="sync_keycloak_users",
|
||||
status=status,
|
||||
message=message,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
logger.info(f"=== Keycloak 同步批次完成 === {message}")
|
||||
return {"status": status, "summary": summary}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Keycloak 同步批次失敗: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
log_batch_execution(
|
||||
batch_name="sync_keycloak_users",
|
||||
status="failed",
|
||||
message=error_msg,
|
||||
started_at=started_at,
|
||||
)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
result = run_sync_keycloak_users()
|
||||
print(f"執行結果: {result}")
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
136
backend/app/core/audit.py
Normal file
136
backend/app/core/audit.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
審計日誌裝飾器和工具函數
|
||||
"""
|
||||
from functools import wraps
|
||||
from typing import Callable, Optional
|
||||
from fastapi import Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def get_current_username() -> str:
|
||||
"""
|
||||
獲取當前用戶名稱
|
||||
|
||||
TODO: 實作後從 JWT Token 獲取
|
||||
目前返回系統用戶
|
||||
"""
|
||||
# TODO: 從 Keycloak JWT Token 解析用戶名
|
||||
return "system@porscheworld.tw"
|
||||
|
||||
|
||||
def audit_log_decorator(
|
||||
action: str,
|
||||
resource_type: str,
|
||||
get_resource_id: Optional[Callable] = None,
|
||||
get_details: Optional[Callable] = None,
|
||||
):
|
||||
"""
|
||||
審計日誌裝飾器
|
||||
|
||||
使用範例:
|
||||
@audit_log_decorator(
|
||||
action="create",
|
||||
resource_type="employee",
|
||||
get_resource_id=lambda result: result.id,
|
||||
get_details=lambda result: {"employee_id": result.employee_id}
|
||||
)
|
||||
def create_employee(...):
|
||||
pass
|
||||
|
||||
Args:
|
||||
action: 操作類型
|
||||
resource_type: 資源類型
|
||||
get_resource_id: 從返回結果獲取資源 ID 的函數
|
||||
get_details: 從返回結果獲取詳細資訊的函數
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
# 執行原函數
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 獲取 DB Session
|
||||
db: Optional[Session] = kwargs.get("db")
|
||||
if not db:
|
||||
return result
|
||||
|
||||
# 獲取 Request (用於 IP)
|
||||
request: Optional[Request] = kwargs.get("request")
|
||||
ip_address = None
|
||||
if request:
|
||||
from app.services.audit_service import audit_service
|
||||
ip_address = audit_service.get_client_ip(request)
|
||||
|
||||
# 獲取資源 ID
|
||||
resource_id = None
|
||||
if get_resource_id and result:
|
||||
resource_id = get_resource_id(result)
|
||||
|
||||
# 獲取詳細資訊
|
||||
details = None
|
||||
if get_details and result:
|
||||
details = get_details(result)
|
||||
|
||||
# 記錄審計日誌
|
||||
from app.services.audit_service import audit_service
|
||||
audit_service.log(
|
||||
db=db,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=get_current_username(),
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
# 執行原函數
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# 獲取 DB Session
|
||||
db: Optional[Session] = kwargs.get("db")
|
||||
if not db:
|
||||
return result
|
||||
|
||||
# 獲取 Request (用於 IP)
|
||||
request: Optional[Request] = kwargs.get("request")
|
||||
ip_address = None
|
||||
if request:
|
||||
from app.services.audit_service import audit_service
|
||||
ip_address = audit_service.get_client_ip(request)
|
||||
|
||||
# 獲取資源 ID
|
||||
resource_id = None
|
||||
if get_resource_id and result:
|
||||
resource_id = get_resource_id(result)
|
||||
|
||||
# 獲取詳細資訊
|
||||
details = None
|
||||
if get_details and result:
|
||||
details = get_details(result)
|
||||
|
||||
# 記錄審計日誌
|
||||
from app.services.audit_service import audit_service
|
||||
audit_service.log(
|
||||
db=db,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=get_current_username(),
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# 檢查是否為異步函數
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
92
backend/app/core/config.py
Normal file
92
backend/app/core/config.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""簡化配置 - 用於測試"""
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 載入 .env 檔案 (必須在讀取環境變數之前)
|
||||
load_dotenv()
|
||||
|
||||
# 直接從環境變數讀取,不依賴 pydantic-settings 的複雜功能
|
||||
class Settings:
|
||||
"""應用配置 (簡化版)"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "HR Portal API")
|
||||
VERSION: str = os.getenv("VERSION", "2.0.0")
|
||||
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
|
||||
# 資料庫
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal")
|
||||
DATABASE_ECHO: bool = os.getenv("DATABASE_ECHO", "False").lower() == "true"
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:10180,http://10.1.0.245:3000,http://10.1.0.245:10180,https://hr.ease.taipei")
|
||||
|
||||
def get_allowed_origins(self):
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_URL: str = os.getenv("KEYCLOAK_URL", "https://auth.ease.taipei")
|
||||
KEYCLOAK_REALM: str = os.getenv("KEYCLOAK_REALM", "porscheworld")
|
||||
KEYCLOAK_CLIENT_ID: str = os.getenv("KEYCLOAK_CLIENT_ID", "hr-backend")
|
||||
KEYCLOAK_CLIENT_SECRET: str = os.getenv("KEYCLOAK_CLIENT_SECRET", "")
|
||||
KEYCLOAK_ADMIN_USERNAME: str = os.getenv("KEYCLOAK_ADMIN_USERNAME", "")
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "")
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
|
||||
# 郵件
|
||||
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "10.1.0.30")
|
||||
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
|
||||
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "True").lower() == "true"
|
||||
MAIL_ADMIN_USER: str = os.getenv("MAIL_ADMIN_USER", "admin@porscheworld.tw")
|
||||
MAIL_ADMIN_PASSWORD: str = os.getenv("MAIL_ADMIN_PASSWORD", "")
|
||||
|
||||
# NAS
|
||||
NAS_HOST: str = os.getenv("NAS_HOST", "10.1.0.30")
|
||||
NAS_PORT: int = int(os.getenv("NAS_PORT", "5000"))
|
||||
NAS_USERNAME: str = os.getenv("NAS_USERNAME", "")
|
||||
NAS_PASSWORD: str = os.getenv("NAS_PASSWORD", "")
|
||||
NAS_WEBDAV_URL: str = os.getenv("NAS_WEBDAV_URL", "https://nas.lab.taipei/webdav")
|
||||
NAS_SMB_SHARE: str = os.getenv("NAS_SMB_SHARE", "Working")
|
||||
|
||||
# 日誌
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FILE: str = os.getenv("LOG_FILE", "logs/hr_portal.log")
|
||||
|
||||
# 分頁
|
||||
DEFAULT_PAGE_SIZE: int = int(os.getenv("DEFAULT_PAGE_SIZE", "20"))
|
||||
MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
|
||||
|
||||
# 郵件配額 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = int(os.getenv("EMAIL_QUOTA_JUNIOR", "1000"))
|
||||
EMAIL_QUOTA_MID: int = int(os.getenv("EMAIL_QUOTA_MID", "2000"))
|
||||
EMAIL_QUOTA_SENIOR: int = int(os.getenv("EMAIL_QUOTA_SENIOR", "5000"))
|
||||
EMAIL_QUOTA_MANAGER: int = int(os.getenv("EMAIL_QUOTA_MANAGER", "10000"))
|
||||
|
||||
# NAS 配額 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = int(os.getenv("NAS_QUOTA_JUNIOR", "50"))
|
||||
NAS_QUOTA_MID: int = int(os.getenv("NAS_QUOTA_MID", "100"))
|
||||
NAS_QUOTA_SENIOR: int = int(os.getenv("NAS_QUOTA_SENIOR", "200"))
|
||||
NAS_QUOTA_MANAGER: int = int(os.getenv("NAS_QUOTA_MANAGER", "500"))
|
||||
|
||||
# Drive Service (Nextcloud 微服務)
|
||||
DRIVE_SERVICE_URL: str = os.getenv("DRIVE_SERVICE_URL", "https://drive-api.ease.taipei")
|
||||
DRIVE_SERVICE_TIMEOUT: int = int(os.getenv("DRIVE_SERVICE_TIMEOUT", "10"))
|
||||
DRIVE_SERVICE_TENANT_ID: int = int(os.getenv("DRIVE_SERVICE_TENANT_ID", "1"))
|
||||
|
||||
# Docker Mailserver SSH 整合
|
||||
MAILSERVER_SSH_HOST: str = os.getenv("MAILSERVER_SSH_HOST", "10.1.0.254")
|
||||
MAILSERVER_SSH_PORT: int = int(os.getenv("MAILSERVER_SSH_PORT", "22"))
|
||||
MAILSERVER_SSH_USER: str = os.getenv("MAILSERVER_SSH_USER", "porsche")
|
||||
MAILSERVER_SSH_PASSWORD: str = os.getenv("MAILSERVER_SSH_PASSWORD", "")
|
||||
MAILSERVER_CONTAINER_NAME: str = os.getenv("MAILSERVER_CONTAINER_NAME", "mailserver")
|
||||
MAILSERVER_SSH_TIMEOUT: int = int(os.getenv("MAILSERVER_SSH_TIMEOUT", "30"))
|
||||
|
||||
# 創建實例
|
||||
settings = Settings()
|
||||
87
backend/app/core/config.py.backup
Normal file
87
backend/app/core/config.py.backup
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
應用配置管理
|
||||
使用 Pydantic Settings 管理環境變數
|
||||
"""
|
||||
from typing import List, Union
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""應用配置"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = "HR Portal API"
|
||||
VERSION: str = "2.0.0"
|
||||
ENVIRONMENT: str = "development" # development, staging, production
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
# 資料庫配置 (使用 psycopg 驅動)
|
||||
DATABASE_URL: str = "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
|
||||
DATABASE_ECHO: bool = False # SQL 查詢日誌
|
||||
|
||||
# CORS 配置 (字串格式,逗號分隔)
|
||||
ALLOWED_ORIGINS: str = "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
|
||||
|
||||
def get_allowed_origins(self) -> List[str]:
|
||||
"""取得 CORS 允許的來源清單"""
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak 配置
|
||||
KEYCLOAK_URL: str = "https://auth.ease.taipei"
|
||||
KEYCLOAK_REALM: str = "porscheworld"
|
||||
KEYCLOAK_CLIENT_ID: str = "hr-backend"
|
||||
KEYCLOAK_CLIENT_SECRET: str = "" # 從環境變數讀取
|
||||
KEYCLOAK_ADMIN_USERNAME: str = ""
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# 郵件配置 (Docker Mailserver)
|
||||
MAIL_SERVER: str = "10.1.0.30"
|
||||
MAIL_PORT: int = 587
|
||||
MAIL_USE_TLS: bool = True
|
||||
MAIL_ADMIN_USER: str = "admin@porscheworld.tw"
|
||||
MAIL_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# NAS 配置 (Synology)
|
||||
NAS_HOST: str = "10.1.0.30"
|
||||
NAS_PORT: int = 5000
|
||||
NAS_USERNAME: str = ""
|
||||
NAS_PASSWORD: str = ""
|
||||
NAS_WEBDAV_URL: str = "https://nas.lab.taipei/webdav"
|
||||
NAS_SMB_SHARE: str = "Working"
|
||||
|
||||
# 日誌配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/hr_portal.log"
|
||||
|
||||
# 分頁配置
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
# 配額配置 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = 1000
|
||||
EMAIL_QUOTA_MID: int = 2000
|
||||
EMAIL_QUOTA_SENIOR: int = 5000
|
||||
EMAIL_QUOTA_MANAGER: int = 10000
|
||||
|
||||
# NAS 配額配置 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = 50
|
||||
NAS_QUOTA_MID: int = 100
|
||||
NAS_QUOTA_SENIOR: int = 200
|
||||
NAS_QUOTA_MANAGER: int = 500
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
)
|
||||
|
||||
|
||||
# 全域配置實例
|
||||
settings = Settings()
|
||||
94
backend/app/core/config.pydantic_backup
Normal file
94
backend/app/core/config.pydantic_backup
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
應用配置管理
|
||||
使用 Pydantic Settings 管理環境變數
|
||||
"""
|
||||
from typing import List, Union
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# 手動載入 .env 檔案 (避免網路磁碟 I/O 延遲問題)
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""應用配置"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = "HR Portal API"
|
||||
VERSION: str = "2.0.0"
|
||||
ENVIRONMENT: str = "development" # development, staging, production
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
# 資料庫配置 (使用 psycopg 驅動)
|
||||
DATABASE_URL: str = "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
|
||||
DATABASE_ECHO: bool = False # SQL 查詢日誌
|
||||
|
||||
# CORS 配置 (字串格式,逗號分隔)
|
||||
ALLOWED_ORIGINS: str = "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
|
||||
|
||||
def get_allowed_origins(self) -> List[str]:
|
||||
"""取得 CORS 允許的來源清單"""
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak 配置
|
||||
KEYCLOAK_URL: str = "https://auth.ease.taipei"
|
||||
KEYCLOAK_REALM: str = "porscheworld"
|
||||
KEYCLOAK_CLIENT_ID: str = "hr-backend"
|
||||
KEYCLOAK_CLIENT_SECRET: str = "" # 從環境變數讀取
|
||||
KEYCLOAK_ADMIN_USERNAME: str = ""
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# 郵件配置 (Docker Mailserver)
|
||||
MAIL_SERVER: str = "10.1.0.30"
|
||||
MAIL_PORT: int = 587
|
||||
MAIL_USE_TLS: bool = True
|
||||
MAIL_ADMIN_USER: str = "admin@porscheworld.tw"
|
||||
MAIL_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# NAS 配置 (Synology)
|
||||
NAS_HOST: str = "10.1.0.30"
|
||||
NAS_PORT: int = 5000
|
||||
NAS_USERNAME: str = ""
|
||||
NAS_PASSWORD: str = ""
|
||||
NAS_WEBDAV_URL: str = "https://nas.lab.taipei/webdav"
|
||||
NAS_SMB_SHARE: str = "Working"
|
||||
|
||||
# 日誌配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/hr_portal.log"
|
||||
|
||||
# 分頁配置
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
# 配額配置 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = 1000
|
||||
EMAIL_QUOTA_MID: int = 2000
|
||||
EMAIL_QUOTA_SENIOR: int = 5000
|
||||
EMAIL_QUOTA_MANAGER: int = 10000
|
||||
|
||||
# NAS 配額配置 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = 50
|
||||
NAS_QUOTA_MID: int = 100
|
||||
NAS_QUOTA_SENIOR: int = 200
|
||||
NAS_QUOTA_MANAGER: int = 500
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
# 不使用 pydantic-settings 的 env_file (避免網路磁碟I/O問題)
|
||||
# 改用 python-dotenv 手動載入 (見檔案開頭)
|
||||
case_sensitive=True,
|
||||
)
|
||||
|
||||
|
||||
# 全域配置實例
|
||||
settings = Settings()
|
||||
77
backend/app/core/config_simple.py
Normal file
77
backend/app/core/config_simple.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""簡化配置 - 用於測試"""
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
|
||||
# 直接從環境變數讀取,不依賴 pydantic-settings 的複雜功能
|
||||
class Settings:
|
||||
"""應用配置 (簡化版)"""
|
||||
|
||||
# 基本資訊
|
||||
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "HR Portal API")
|
||||
VERSION: str = os.getenv("VERSION", "2.0.0")
|
||||
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
|
||||
# 資料庫
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal")
|
||||
DATABASE_ECHO: bool = os.getenv("DATABASE_ECHO", "False").lower() == "true"
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei")
|
||||
|
||||
def get_allowed_origins(self):
|
||||
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
|
||||
|
||||
# Keycloak
|
||||
KEYCLOAK_URL: str = os.getenv("KEYCLOAK_URL", "https://auth.ease.taipei")
|
||||
KEYCLOAK_REALM: str = os.getenv("KEYCLOAK_REALM", "porscheworld")
|
||||
KEYCLOAK_CLIENT_ID: str = os.getenv("KEYCLOAK_CLIENT_ID", "hr-backend")
|
||||
KEYCLOAK_CLIENT_SECRET: str = os.getenv("KEYCLOAK_CLIENT_SECRET", "")
|
||||
KEYCLOAK_ADMIN_USERNAME: str = os.getenv("KEYCLOAK_ADMIN_USERNAME", "")
|
||||
KEYCLOAK_ADMIN_PASSWORD: str = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "")
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
|
||||
# 郵件
|
||||
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "10.1.0.30")
|
||||
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
|
||||
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "True").lower() == "true"
|
||||
MAIL_ADMIN_USER: str = os.getenv("MAIL_ADMIN_USER", "admin@porscheworld.tw")
|
||||
MAIL_ADMIN_PASSWORD: str = os.getenv("MAIL_ADMIN_PASSWORD", "")
|
||||
|
||||
# NAS
|
||||
NAS_HOST: str = os.getenv("NAS_HOST", "10.1.0.30")
|
||||
NAS_PORT: int = int(os.getenv("NAS_PORT", "5000"))
|
||||
NAS_USERNAME: str = os.getenv("NAS_USERNAME", "")
|
||||
NAS_PASSWORD: str = os.getenv("NAS_PASSWORD", "")
|
||||
NAS_WEBDAV_URL: str = os.getenv("NAS_WEBDAV_URL", "https://nas.lab.taipei/webdav")
|
||||
NAS_SMB_SHARE: str = os.getenv("NAS_SMB_SHARE", "Working")
|
||||
|
||||
# 日誌
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FILE: str = os.getenv("LOG_FILE", "logs/hr_portal.log")
|
||||
|
||||
# 分頁
|
||||
DEFAULT_PAGE_SIZE: int = int(os.getenv("DEFAULT_PAGE_SIZE", "20"))
|
||||
MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
|
||||
|
||||
# 郵件配額 (MB)
|
||||
EMAIL_QUOTA_JUNIOR: int = int(os.getenv("EMAIL_QUOTA_JUNIOR", "1000"))
|
||||
EMAIL_QUOTA_MID: int = int(os.getenv("EMAIL_QUOTA_MID", "2000"))
|
||||
EMAIL_QUOTA_SENIOR: int = int(os.getenv("EMAIL_QUOTA_SENIOR", "5000"))
|
||||
EMAIL_QUOTA_MANAGER: int = int(os.getenv("EMAIL_QUOTA_MANAGER", "10000"))
|
||||
|
||||
# NAS 配額 (GB)
|
||||
NAS_QUOTA_JUNIOR: int = int(os.getenv("NAS_QUOTA_JUNIOR", "50"))
|
||||
NAS_QUOTA_MID: int = int(os.getenv("NAS_QUOTA_MID", "100"))
|
||||
NAS_QUOTA_SENIOR: int = int(os.getenv("NAS_QUOTA_SENIOR", "200"))
|
||||
NAS_QUOTA_MANAGER: int = int(os.getenv("NAS_QUOTA_MANAGER", "500"))
|
||||
|
||||
# 載入 .env 並創建實例
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
settings = Settings()
|
||||
11
backend/app/core/config_test.py
Normal file
11
backend/app/core/config_test.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""測試配置"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class TestSettings(BaseSettings):
|
||||
PROJECT_NAME: str = "Test"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = TestSettings()
|
||||
print(f"[OK] Settings loaded: {settings.PROJECT_NAME}")
|
||||
54
backend/app/core/logging_config.py
Normal file
54
backend/app/core/logging_config.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
日誌配置
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""設置日誌系統"""
|
||||
# 延遲導入避免循環依賴
|
||||
from app.core.config import settings
|
||||
|
||||
# 創建日誌目錄
|
||||
log_file = Path(settings.LOG_FILE)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 根日誌器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, settings.LOG_LEVEL))
|
||||
|
||||
# 格式化器
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# JSON 格式化器 (生產環境)
|
||||
json_formatter = jsonlogger.JsonFormatter(
|
||||
"%(asctime)s %(name)s %(levelname)s %(message)s"
|
||||
)
|
||||
|
||||
# 控制台處理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# 文件處理器
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
if settings.ENVIRONMENT == "production":
|
||||
file_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# 添加處理器
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# 設置第三方日誌級別
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
10
backend/app/db/base.py
Normal file
10
backend/app/db/base.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
資料庫 Base 類別
|
||||
所有 SQLAlchemy Model 都繼承自此
|
||||
"""
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# NOTE: 不在這裡匯入 models,避免循環導入
|
||||
# Models 的匯入應該在 alembic/env.py 中處理
|
||||
50
backend/app/db/session.py
Normal file
50
backend/app/db/session.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
資料庫連線管理 (延遲初始化版本)
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
|
||||
# 延遲初始化
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""獲取資料庫引擎 (延遲初始化)"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
from app.core.config import settings
|
||||
_engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DATABASE_ECHO,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_local():
|
||||
"""獲取 Session 工廠 (延遲初始化)"""
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=get_engine(),
|
||||
)
|
||||
return _SessionLocal
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
取得資料庫 Session
|
||||
用於 FastAPI 依賴注入
|
||||
"""
|
||||
SessionLocal = get_session_local()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
52
backend/app/db/session_old.py
Normal file
52
backend/app/db/session_old.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
資料庫連線管理
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# 延遲初始化避免模組導入時就連接資料庫
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
def get_engine():
|
||||
"""獲取資料庫引擎 (延遲初始化)"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DATABASE_ECHO,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
return _engine
|
||||
|
||||
def get_session_local():
|
||||
"""獲取 Session 工廠 (延遲初始化)"""
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=get_engine(),
|
||||
)
|
||||
return _SessionLocal
|
||||
|
||||
# 向後兼容
|
||||
engine = property(lambda self: get_engine())
|
||||
SessionLocal = property(lambda self: get_session_local())
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
取得資料庫 Session
|
||||
用於 FastAPI 依賴注入
|
||||
"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
105
backend/app/main.py
Normal file
105
backend/app/main.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
HR Portal Backend API
|
||||
FastAPI 主應用程式
|
||||
"""
|
||||
import traceback
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging_config import setup_logging
|
||||
from app.db.session import get_engine
|
||||
from app.db.base import Base
|
||||
|
||||
# 設置日誌
|
||||
setup_logging()
|
||||
|
||||
# 創建 FastAPI 應用
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
description="HR Portal - 人力資源管理系統 API",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
# CORS 設定
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.get_allowed_origins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# 全局異常處理器
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""全局異常處理器 - 記錄所有未捕獲的異常"""
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"[ERROR] Unhandled Exception in {request.method} {request.url.path}")
|
||||
print(f"Exception Type: {type(exc).__name__}")
|
||||
print(f"Exception Message: {str(exc)}")
|
||||
print(f"Traceback:")
|
||||
print(traceback.format_exc())
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": str(exc),
|
||||
"type": type(exc).__name__,
|
||||
"path": request.url.path
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 啟動事件
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""應用啟動時執行"""
|
||||
# 資料庫表格由 Alembic 管理,不需要在啟動時創建
|
||||
print(f"[OK] {settings.PROJECT_NAME} v{settings.VERSION} started!")
|
||||
print(f"[*] Environment: {settings.ENVIRONMENT}")
|
||||
print(f"[*] API Documentation: http://{settings.HOST}:{settings.PORT}/docs")
|
||||
|
||||
|
||||
# 關閉事件
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""應用關閉時執行"""
|
||||
print(f"[*] {settings.PROJECT_NAME} stopped")
|
||||
|
||||
|
||||
# 健康檢查端點
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""健康檢查"""
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "healthy",
|
||||
"service": settings.PROJECT_NAME,
|
||||
"version": settings.VERSION,
|
||||
"environment": settings.ENVIRONMENT,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 根路徑
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
"""根路徑"""
|
||||
return {
|
||||
"message": f"Welcome to {settings.PROJECT_NAME}",
|
||||
"version": settings.VERSION,
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc",
|
||||
}
|
||||
|
||||
|
||||
# 導入並註冊 API 路由
|
||||
from app.api.v1.router import api_router
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
84
backend/app/models/__init__.py
Normal file
84
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Models 模組
|
||||
匯出所有資料庫模型
|
||||
"""
|
||||
# 多租戶核心模型
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.models.system_function_cache import SystemFunctionCache
|
||||
from app.models.system_function import SystemFunction
|
||||
from app.models.personal_service import PersonalService
|
||||
|
||||
# HR 組織架構
|
||||
from app.models.department import Department
|
||||
from app.models.department_member import DepartmentMember
|
||||
|
||||
# HR 員工模型
|
||||
from app.models.employee import Employee, EmployeeStatus
|
||||
from app.models.emp_resume import EmpResume
|
||||
from app.models.emp_setting import EmpSetting
|
||||
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
|
||||
|
||||
# RBAC 權限系統
|
||||
from app.models.role import UserRole, RoleRight, UserRoleAssignment
|
||||
|
||||
# 其他業務模型
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.models.network_drive import NetworkDrive
|
||||
from app.models.permission import Permission
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.batch_log import BatchLog
|
||||
|
||||
# 初始化系統
|
||||
from app.models.installation import (
|
||||
InstallationSession,
|
||||
InstallationChecklistItem,
|
||||
InstallationChecklistResult,
|
||||
InstallationStep,
|
||||
InstallationLog,
|
||||
InstallationTenantInfo,
|
||||
InstallationDepartmentSetup,
|
||||
TemporaryPassword,
|
||||
InstallationAccessLog,
|
||||
InstallationEnvironmentConfig,
|
||||
InstallationSystemStatus
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 多租戶核心
|
||||
"Tenant",
|
||||
"TenantStatus",
|
||||
"SystemFunctionCache",
|
||||
"SystemFunction",
|
||||
"PersonalService",
|
||||
# 組織架構
|
||||
"Department",
|
||||
"DepartmentMember",
|
||||
# 員工模型
|
||||
"Employee",
|
||||
"EmployeeStatus",
|
||||
"EmpResume",
|
||||
"EmpSetting",
|
||||
"EmpPersonalServiceSetting",
|
||||
# RBAC 權限系統
|
||||
"UserRole",
|
||||
"RoleRight",
|
||||
"UserRoleAssignment",
|
||||
# 其他業務
|
||||
"EmailAccount",
|
||||
"NetworkDrive",
|
||||
"Permission",
|
||||
"AuditLog",
|
||||
"BatchLog",
|
||||
# 初始化系統
|
||||
"InstallationSession",
|
||||
"InstallationChecklistItem",
|
||||
"InstallationChecklistResult",
|
||||
"InstallationStep",
|
||||
"InstallationLog",
|
||||
"InstallationTenantInfo",
|
||||
"InstallationDepartmentSetup",
|
||||
"TemporaryPassword",
|
||||
"InstallationAccessLog",
|
||||
"InstallationEnvironmentConfig",
|
||||
"InstallationSystemStatus",
|
||||
]
|
||||
64
backend/app/models/audit_log.py
Normal file
64
backend/app/models/audit_log.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
審計日誌 Model
|
||||
記錄所有關鍵操作,符合 ISO 要求
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, JSON, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""審計日誌表"""
|
||||
|
||||
__tablename__ = "tenant_audit_logs"
|
||||
__table_args__ = (
|
||||
Index("idx_audit_tenant_action", "tenant_id", "action"),
|
||||
Index("idx_audit_tenant_resource", "tenant_id", "resource_type", "resource_id"),
|
||||
Index("idx_audit_tenant_time", "tenant_id", "performed_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
action = Column(String(50), nullable=False, index=True, comment="操作類型 (create/update/delete/login)")
|
||||
resource_type = Column(String(50), nullable=False, index=True, comment="資源類型 (employee/department/role)")
|
||||
resource_id = Column(Integer, nullable=True, index=True, comment="資源 ID")
|
||||
performed_by = Column(String(100), nullable=False, index=True, comment="操作者 SSO 帳號")
|
||||
performed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True, comment="操作時間")
|
||||
details = Column(JSONB, nullable=True, comment="詳細變更內容 (JSON)")
|
||||
ip_address = Column(String(45), nullable=True, comment="IP 位址 (IPv4/IPv6)")
|
||||
|
||||
# 通用欄位 (Note: audit_logs 不需要 is_active,只記錄不修改)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog {self.action} {self.resource_type}:{self.resource_id} by {self.performed_by}>"
|
||||
|
||||
@classmethod
|
||||
def create_log(
|
||||
cls,
|
||||
tenant_id: int,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
performed_by: str,
|
||||
resource_id: int = None,
|
||||
details: dict = None,
|
||||
ip_address: str = None,
|
||||
) -> "AuditLog":
|
||||
"""創建審計日誌"""
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
31
backend/app/models/batch_log.py
Normal file
31
backend/app/models/batch_log.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
批次執行日誌 Model
|
||||
記錄所有批次作業的執行結果
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class BatchLog(Base):
|
||||
"""批次執行日誌表"""
|
||||
|
||||
__tablename__ = "tenant_batch_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
batch_name = Column(String(100), nullable=False, index=True, comment="批次名稱")
|
||||
status = Column(String(20), nullable=False, comment="執行狀態: success/failed/warning")
|
||||
message = Column(Text, comment="執行訊息或錯誤詳情")
|
||||
started_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True, comment="開始時間")
|
||||
finished_at = Column(DateTime, comment="完成時間")
|
||||
duration_seconds = Column(Integer, comment="執行時間 (秒)")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BatchLog {self.batch_name} [{self.status}] @ {self.started_at}>"
|
||||
49
backend/app/models/business_unit.py.deprecated
Normal file
49
backend/app/models/business_unit.py.deprecated
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
事業部 Model
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class BusinessUnit(Base):
|
||||
"""事業部表"""
|
||||
|
||||
__tablename__ = "business_units"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_tenant_bu_code"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
name = Column(String(100), nullable=False, comment="事業部名稱")
|
||||
name_en = Column(String(100), comment="英文名稱")
|
||||
code = Column(String(20), nullable=False, index=True, comment="事業部代碼 (BD, TD, OM, 租戶內唯一)")
|
||||
email_domain = Column(String(100), unique=True, nullable=False, comment="郵件網域 (ease.taipei, lab.taipei, porscheworld.tw)")
|
||||
description = Column(Text, comment="說明")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Phase 2.2 新增欄位
|
||||
primary_domain = Column(String(100), comment="主要網域 (與 email_domain 相同)")
|
||||
email_address = Column(String(255), comment="事業部信箱 (例如: business@ease.taipei)")
|
||||
email_quota_mb = Column(Integer, default=10240, nullable=False, comment="事業部信箱配額 (MB)")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="business_units")
|
||||
# departments relationship 已移除 (business_unit_id FK 已從 departments 表刪除於 migration 0005)
|
||||
employee_identities = relationship(
|
||||
"EmployeeIdentity",
|
||||
back_populates="business_unit",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BusinessUnit {self.code} - {self.name}>"
|
||||
|
||||
@property
|
||||
def sso_domain(self) -> str:
|
||||
"""SSO 帳號網域"""
|
||||
return self.email_domain
|
||||
74
backend/app/models/department.py
Normal file
74
backend/app/models/department.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
部門 Model
|
||||
統一樹狀部門結構:
|
||||
- depth=0, parent_id=NULL: 第一層部門 (原事業部),可設定 email_domain
|
||||
- depth=1+: 子部門,繼承上層 email_domain
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
"""部門表 (統一樹狀結構)"""
|
||||
|
||||
__tablename__ = "tenant_departments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_dept_seq"),
|
||||
UniqueConstraint("tenant_id", "parent_id", "code", name="uq_tenant_parent_dept_code"),
|
||||
Index("idx_dept_tenant_id", "tenant_id"),
|
||||
Index("idx_departments_parent", "parent_id"),
|
||||
Index("idx_departments_depth", "depth"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
parent_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=True,
|
||||
comment="上層部門 ID (NULL=第一層,即原事業部)")
|
||||
code = Column(String(20), nullable=False, comment="部門代碼 (同層內唯一)")
|
||||
name = Column(String(100), nullable=False, comment="部門名稱")
|
||||
name_en = Column(String(100), nullable=True, comment="英文名稱")
|
||||
email_domain = Column(String(100), nullable=True,
|
||||
comment="郵件網域 (只有 depth=0 可設定,例如 ease.taipei)")
|
||||
email_address = Column(String(255), nullable=True, comment="部門信箱 (例如: wind@ease.taipei)")
|
||||
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="部門信箱配額 (MB)")
|
||||
depth = Column(Integer, default=0, nullable=False, comment="層次深度 (0=第一層,1=第二層,以此類推)")
|
||||
description = Column(Text, comment="說明")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="departments")
|
||||
parent = relationship("Department", back_populates="children", remote_side="Department.id")
|
||||
children = relationship("Department", back_populates="parent", cascade="all, delete-orphan")
|
||||
members = relationship(
|
||||
"DepartmentMember",
|
||||
back_populates="department",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Department depth={self.depth} code={self.code} name={self.name}>"
|
||||
|
||||
@property
|
||||
def effective_email_domain(self) -> str | None:
|
||||
"""有效郵件網域 (第一層自身設定,子層追溯上層)"""
|
||||
if self.depth == 0:
|
||||
return self.email_domain
|
||||
if self.parent:
|
||||
return self.parent.effective_email_domain
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_top_level(self) -> bool:
|
||||
"""是否為第一層部門 (原事業部)"""
|
||||
return self.depth == 0 and self.parent_id is None
|
||||
53
backend/app/models/department_member.py
Normal file
53
backend/app/models/department_member.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
部門成員 Model
|
||||
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DepartmentMember(Base):
|
||||
"""部門成員表"""
|
||||
|
||||
__tablename__ = "tenant_dept_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "department_id", name="uq_employee_department"),
|
||||
Index("idx_dept_members_tenant", "tenant_id"),
|
||||
Index("idx_dept_members_employee", "employee_id"),
|
||||
Index("idx_dept_members_department", "department_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="員工 ID")
|
||||
department_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="部門 ID")
|
||||
position = Column(String(100), nullable=True, comment="在該部門的職稱")
|
||||
membership_type = Column(String(50), default="permanent", nullable=False,
|
||||
comment="成員類型: permanent/temporary/project")
|
||||
|
||||
# 時間記錄(審計追蹤)
|
||||
joined_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="加入時間")
|
||||
ended_at = Column(DateTime, nullable=True, comment="離開時間(軟刪除)")
|
||||
|
||||
# 審計欄位
|
||||
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
|
||||
removed_by = Column(String(36), nullable=True, comment="移除者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="department_memberships")
|
||||
department = relationship("Department", back_populates="members")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DepartmentMember employee_id={self.employee_id} department_id={self.department_id}>"
|
||||
123
backend/app/models/email_account.py
Normal file
123
backend/app/models/email_account.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
郵件帳號 Model
|
||||
支援員工在不同網域擁有多個郵件帳號,並管理配額
|
||||
符合設計文件規範: HR Portal設計文件.md
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmailAccount(Base):
|
||||
"""郵件帳號表 (一個員工可以有多個郵件帳號)"""
|
||||
|
||||
__tablename__ = "tenant_email_accounts"
|
||||
__table_args__ = (
|
||||
# 郵件地址必須唯一
|
||||
Index("idx_email_accounts_email", "email_address", unique=True),
|
||||
# 員工索引
|
||||
Index("idx_email_accounts_employee", "employee_id"),
|
||||
# 租戶索引
|
||||
Index("idx_email_accounts_tenant", "tenant_id"),
|
||||
# 狀態索引 (快速查詢啟用的帳號)
|
||||
Index("idx_email_accounts_active", "is_active"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租戶 ID"
|
||||
)
|
||||
|
||||
# 支援個人/部門信箱
|
||||
account_type = Column(
|
||||
String(20),
|
||||
default='personal',
|
||||
nullable=False,
|
||||
comment="帳號類型: personal(個人), department(部門)"
|
||||
)
|
||||
employee_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
|
||||
nullable=True, # 部門信箱不需要 employee_id
|
||||
index=True,
|
||||
comment="員工 ID (僅 personal 類型需要)"
|
||||
)
|
||||
department_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_departments.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="部門 ID (僅 department 類型需要)"
|
||||
)
|
||||
|
||||
# 郵件設定
|
||||
email_address = Column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="郵件地址 (例如: porsche.chen@lab.taipei)"
|
||||
)
|
||||
quota_mb = Column(
|
||||
Integer,
|
||||
default=2048,
|
||||
nullable=False,
|
||||
comment="配額 (MB),依職級: Junior=2048, Mid=3072, Senior=5120, Manager=10240"
|
||||
)
|
||||
|
||||
# 進階功能
|
||||
forward_to = Column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="轉寄地址 (可選,例如外部郵箱)"
|
||||
)
|
||||
auto_reply = Column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="自動回覆內容 (可選,例如休假通知)"
|
||||
)
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="email_accounts")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmailAccount {self.email_address} (配額:{self.quota_mb}MB)>"
|
||||
|
||||
@property
|
||||
def local_part(self) -> str:
|
||||
"""郵件前綴 (@ 之前的部分)"""
|
||||
return self.email_address.split('@')[0] if '@' in self.email_address else self.email_address
|
||||
|
||||
@property
|
||||
def domain_part(self) -> str:
|
||||
"""網域部分 (@ 之後的部分)"""
|
||||
return self.email_address.split('@')[1] if '@' in self.email_address else ""
|
||||
|
||||
@property
|
||||
def quota_gb(self) -> float:
|
||||
"""配額 (GB,用於顯示)"""
|
||||
return round(self.quota_mb / 1024, 2)
|
||||
|
||||
@classmethod
|
||||
def get_default_quota_by_level(cls, job_level: str) -> int:
|
||||
"""根據職級取得預設配額 (MB)"""
|
||||
quota_map = {
|
||||
"Junior": 2048,
|
||||
"Mid": 3072,
|
||||
"Senior": 5120,
|
||||
"Manager": 10240,
|
||||
}
|
||||
return quota_map.get(job_level, 2048)
|
||||
50
backend/app/models/emp_personal_service_setting.py
Normal file
50
backend/app/models/emp_personal_service_setting.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
員工個人化服務設定 Model
|
||||
記錄員工啟用的個人化服務(SSO, Email, Calendar, Drive, Office)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpPersonalServiceSetting(Base):
|
||||
"""員工個人化服務設定表"""
|
||||
|
||||
__tablename__ = "tenant_emp_personal_service_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "keycloak_user_id", "service_id", name="uq_emp_service"),
|
||||
Index("idx_emp_service_tenant", "tenant_id"),
|
||||
Index("idx_emp_service_user", "keycloak_user_id"),
|
||||
Index("idx_emp_service_service", "service_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID")
|
||||
service_id = Column(Integer, ForeignKey("personal_services.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="個人化服務 ID")
|
||||
|
||||
# 服務配額設定(依服務類型不同)
|
||||
quota_gb = Column(Integer, nullable=True, comment="儲存配額 (GB),適用於 Drive")
|
||||
quota_mb = Column(Integer, nullable=True, comment="郵件配額 (MB),適用於 Email")
|
||||
|
||||
# 審計欄位(完整記錄)
|
||||
enabled_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="啟用時間")
|
||||
enabled_by = Column(String(36), nullable=True, comment="啟用者 keycloak_user_id")
|
||||
disabled_at = Column(DateTime, nullable=True, comment="停用時間(軟刪除)")
|
||||
disabled_by = Column(String(36), nullable=True, comment="停用者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
service = relationship("PersonalService")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpPersonalServiceSetting user={self.keycloak_user_id} service={self.service_id}>"
|
||||
69
backend/app/models/emp_resume.py
Normal file
69
backend/app/models/emp_resume.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
員工履歷資料 Model (人員基本檔)
|
||||
記錄員工的個人資料、教育背景等(與任用無關的基本資料)
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpResume(Base):
|
||||
"""員工履歷表(人員基本檔)"""
|
||||
|
||||
__tablename__ = "tenant_emp_resumes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_resume_seq"),
|
||||
UniqueConstraint("tenant_id", "id_number", name="uq_tenant_id_number"),
|
||||
Index("idx_emp_resume_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
|
||||
# 個人基本資料
|
||||
legal_name = Column(String(100), nullable=False, comment="法定姓名")
|
||||
english_name = Column(String(100), nullable=True, comment="英文名稱")
|
||||
id_number = Column(String(20), nullable=False, comment="身分證字號/護照號碼")
|
||||
birth_date = Column(Date, nullable=True, comment="出生日期")
|
||||
gender = Column(String(10), nullable=True, comment="性別: M/F/Other")
|
||||
marital_status = Column(String(20), nullable=True, comment="婚姻狀況: single/married/divorced/widowed")
|
||||
nationality = Column(String(50), nullable=True, comment="國籍")
|
||||
|
||||
# 聯絡資訊
|
||||
phone = Column(String(20), nullable=True, comment="聯絡電話")
|
||||
mobile = Column(String(20), nullable=True, comment="手機")
|
||||
personal_email = Column(String(255), nullable=True, comment="個人郵箱")
|
||||
address = Column(Text, nullable=True, comment="通訊地址")
|
||||
emergency_contact = Column(String(100), nullable=True, comment="緊急聯絡人")
|
||||
emergency_phone = Column(String(20), nullable=True, comment="緊急聯絡電話")
|
||||
|
||||
# 教育背景
|
||||
education_level = Column(String(50), nullable=True, comment="學歷: high_school/bachelor/master/phd")
|
||||
school_name = Column(String(200), nullable=True, comment="畢業學校")
|
||||
major = Column(String(100), nullable=True, comment="主修科系")
|
||||
graduation_year = Column(Integer, nullable=True, comment="畢業年份")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
employment_setting = relationship(
|
||||
"EmpSetting",
|
||||
back_populates="resume",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpResume {self.legal_name} ({self.id_number})>"
|
||||
86
backend/app/models/emp_setting.py
Normal file
86
backend/app/models/emp_setting.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
員工任用設定 Model (員工任用資料檔)
|
||||
記錄員工的任用資訊、職務、薪資等(與組織任用相關的資料)
|
||||
使用複合主鍵 (tenant_id, seq_no)
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpSetting(Base):
|
||||
"""員工任用設定表(複合主鍵)"""
|
||||
|
||||
__tablename__ = "tenant_emp_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "tenant_resume_id", name="uq_tenant_resume_setting"),
|
||||
UniqueConstraint("tenant_id", "tenant_emp_code", name="uq_tenant_emp_code"),
|
||||
Index("idx_emp_setting_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
# 複合主鍵
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, primary_key=True, comment="租戶內序號 (觸發器自動生成)")
|
||||
|
||||
# 關聯人員基本檔
|
||||
tenant_resume_id = Column(Integer, ForeignKey("tenant_emp_resumes.id", ondelete="RESTRICT"), nullable=False,
|
||||
comment="人員基本檔 ID(一個人只有一筆任用設定)")
|
||||
|
||||
# 員工編號(自動生成)
|
||||
tenant_emp_code = Column(String(20), nullable=False, index=True,
|
||||
comment="員工編號(自動生成,格式: prefix + seq_no,例如 PWD0001)")
|
||||
|
||||
# SSO 整合
|
||||
tenant_keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變)")
|
||||
tenant_keycloak_username = Column(String(100), unique=True, nullable=True,
|
||||
comment="Keycloak 登入帳號")
|
||||
|
||||
# 任用資訊
|
||||
hire_at = Column(Date, nullable=False, comment="到職日期")
|
||||
resign_date = Column(Date, nullable=True, comment="離職日期")
|
||||
job_title = Column(String(100), nullable=True, comment="職稱")
|
||||
employment_type = Column(String(50), nullable=False, default="full_time",
|
||||
comment="任用類型: full_time/part_time/contractor/intern")
|
||||
|
||||
# 薪資資訊(加密儲存)
|
||||
salary_amount = Column(Integer, nullable=True, comment="月薪(加密)")
|
||||
salary_currency = Column(String(10), default="TWD", comment="薪資幣別")
|
||||
|
||||
# 主要部門(員工可屬於多個部門,但有一個主要部門)
|
||||
primary_dept_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="SET NULL"), nullable=True,
|
||||
comment="主要部門 ID")
|
||||
|
||||
# 個人化服務配額設定
|
||||
storage_quota_gb = Column(Integer, default=20, nullable=False, comment="儲存配額 (GB) - Drive 使用")
|
||||
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="郵件配額 (MB) - Email 使用")
|
||||
|
||||
# 狀態
|
||||
employment_status = Column(String(20), default="active", nullable=False,
|
||||
comment="任用狀態: active/on_leave/resigned/terminated")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
resume = relationship("EmpResume", back_populates="employment_setting")
|
||||
primary_department = relationship("Department", foreign_keys=[primary_dept_id])
|
||||
|
||||
# 關聯:部門歸屬(多對多)- 透過 resume 的 employee 關聯
|
||||
# department_memberships 在 Employee Model 中定義
|
||||
|
||||
# 關聯:角色分配(多對多)- 透過 keycloak_user_id 查詢
|
||||
# user_role_assignments 在 UserRoleAssignment Model 中定義
|
||||
|
||||
# 關聯:個人化服務設定(多對多)- 透過 keycloak_user_id 查詢
|
||||
# personal_service_settings 在 EmpPersonalServiceSetting Model 中定義
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpSetting {self.tenant_emp_code} (tenant_id={self.tenant_id}, seq_no={self.seq_no})>"
|
||||
85
backend/app/models/employee.py
Normal file
85
backend/app/models/employee.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
員工基本資料 Model
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Enum, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmployeeStatus(str, enum.Enum):
|
||||
"""員工狀態"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
TERMINATED = "terminated"
|
||||
|
||||
|
||||
class Employee(Base):
|
||||
"""員工基本資料表"""
|
||||
|
||||
__tablename__ = "tenant_employees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", name="uq_tenant_employee_id"),
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_seq_no"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (自動從1開始)")
|
||||
employee_id = Column(String(20), nullable=False, index=True, comment="員工編號 (EMP001, 租戶內唯一,永久不變)")
|
||||
keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變,一個員工只有一個)")
|
||||
username_base = Column(String(50), unique=True, nullable=False, index=True, comment="基礎帳號名稱 (全系統唯一)")
|
||||
legal_name = Column(String(100), nullable=False, comment="法定姓名")
|
||||
english_name = Column(String(100), comment="英文名稱")
|
||||
phone = Column(String(20), comment="電話")
|
||||
mobile = Column(String(20), comment="手機")
|
||||
hire_date = Column(Date, nullable=False, comment="到職日期")
|
||||
status = Column(
|
||||
String(20),
|
||||
default=EmployeeStatus.ACTIVE,
|
||||
nullable=False,
|
||||
comment="狀態 (active/inactive/terminated)"
|
||||
)
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="employees")
|
||||
department_memberships = relationship(
|
||||
"DepartmentMember",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
email_accounts = relationship(
|
||||
"EmailAccount",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
permissions = relationship(
|
||||
"Permission",
|
||||
foreign_keys="Permission.employee_id",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
network_drive = relationship(
|
||||
"NetworkDrive",
|
||||
back_populates="employee",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Employee {self.employee_id} - {self.legal_name}>"
|
||||
|
||||
# is_active 已改為資料庫欄位,移除 @property
|
||||
66
backend/app/models/employee_identity.py.deprecated
Normal file
66
backend/app/models/employee_identity.py.deprecated
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
員工身份 Model
|
||||
一個員工可以在多個事業部任職,每個事業部對應一個身份
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmployeeIdentity(Base):
|
||||
"""員工身份表"""
|
||||
|
||||
__tablename__ = "employee_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", "business_unit_id", name="uq_tenant_emp_bu"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# SSO 帳號 (= 郵件地址)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True, comment="SSO 帳號 (porsche.chen@lab.taipei)")
|
||||
keycloak_id = Column(String(100), unique=True, nullable=False, index=True, comment="Keycloak UUID")
|
||||
|
||||
# 組織與職務
|
||||
business_unit_id = Column(Integer, ForeignKey("business_units.id"), nullable=False, index=True)
|
||||
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True, index=True)
|
||||
job_title = Column(String(100), nullable=False, comment="職稱")
|
||||
job_level = Column(String(20), nullable=False, comment="職級 (Junior/Mid/Senior/Manager)")
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要身份")
|
||||
|
||||
# 郵件配額
|
||||
email_quota_mb = Column(Integer, nullable=False, comment="郵件配額 (MB)")
|
||||
|
||||
# 時間記錄
|
||||
started_at = Column(Date, nullable=False, comment="開始日期")
|
||||
ended_at = Column(Date, nullable=True, comment="結束日期")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
employee = relationship("Employee", back_populates="identities")
|
||||
business_unit = relationship("BusinessUnit", back_populates="employee_identities")
|
||||
department = relationship("Department") # back_populates 已移除 (employee_identities 廢棄)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmployeeIdentity {self.username}>"
|
||||
|
||||
@property
|
||||
def email(self) -> str:
|
||||
"""郵件地址 (= SSO 帳號)"""
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def is_cross_department(self) -> bool:
|
||||
"""是否跨部門任職 (檢查同一員工是否有其他身份)"""
|
||||
return len(self.employee.identities) > 1
|
||||
|
||||
def generate_username(self, username_base: str, email_domain: str) -> str:
|
||||
"""生成 SSO 帳號"""
|
||||
return f"{username_base}@{email_domain}"
|
||||
362
backend/app/models/installation.py
Normal file
362
backend/app/models/installation.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Installation System Models
|
||||
初始化系統資料模型
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Boolean, Text, TIMESTAMP, ForeignKey, ARRAY
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InstallationSession(Base):
|
||||
"""安裝會話"""
|
||||
__tablename__ = "installation_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_name = Column(String(200))
|
||||
environment = Column(String(20)) # development/testing/production
|
||||
|
||||
# 狀態追蹤
|
||||
started_at = Column(TIMESTAMP, default=datetime.now)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
status = Column(String(20), default='in_progress') # in_progress/completed/failed/paused
|
||||
|
||||
# 進度統計
|
||||
total_checklist_items = Column(Integer)
|
||||
passed_checklist_items = Column(Integer, default=0)
|
||||
failed_checklist_items = Column(Integer, default=0)
|
||||
total_steps = Column(Integer)
|
||||
completed_steps = Column(Integer, default=0)
|
||||
failed_steps = Column(Integer, default=0)
|
||||
|
||||
executed_by = Column(String(100))
|
||||
|
||||
# 存取控制
|
||||
is_locked = Column(Boolean, default=False)
|
||||
locked_at = Column(TIMESTAMP)
|
||||
locked_by = Column(String(100))
|
||||
lock_reason = Column(String(200))
|
||||
|
||||
is_unlocked = Column(Boolean, default=False)
|
||||
unlocked_at = Column(TIMESTAMP)
|
||||
unlocked_by = Column(String(100))
|
||||
unlock_reason = Column(String(200))
|
||||
unlock_expires_at = Column(TIMESTAMP)
|
||||
|
||||
last_viewed_at = Column(TIMESTAMP)
|
||||
last_viewed_by = Column(String(100))
|
||||
view_count = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
tenant_info = relationship("InstallationTenantInfo", back_populates="session", uselist=False)
|
||||
department_setups = relationship("InstallationDepartmentSetup", back_populates="session")
|
||||
temporary_passwords = relationship("TemporaryPassword", back_populates="session")
|
||||
access_logs = relationship("InstallationAccessLog", back_populates="session")
|
||||
checklist_results = relationship("InstallationChecklistResult", back_populates="session")
|
||||
installation_logs = relationship("InstallationLog", back_populates="session")
|
||||
|
||||
|
||||
class InstallationChecklistItem(Base):
|
||||
"""檢查項目定義(系統級)"""
|
||||
__tablename__ = "installation_checklist_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category = Column(String(50), nullable=False) # hardware/network/software/container/security
|
||||
item_code = Column(String(100), unique=True, nullable=False)
|
||||
item_name = Column(String(200), nullable=False)
|
||||
check_type = Column(String(50), nullable=False) # command/api/config/manual
|
||||
check_command = Column(Text) # 自動檢查命令
|
||||
expected_value = Column(Text)
|
||||
min_requirement = Column(Text)
|
||||
recommended_value = Column(Text)
|
||||
is_required = Column(Boolean, default=True)
|
||||
sequence_order = Column(Integer, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
results = relationship("InstallationChecklistResult", back_populates="checklist_item")
|
||||
|
||||
|
||||
class InstallationChecklistResult(Base):
|
||||
"""檢查結果(租戶級)"""
|
||||
__tablename__ = "installation_checklist_results"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
|
||||
checklist_item_id = Column(Integer, ForeignKey("installation_checklist_items.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(String(20), nullable=False) # pass/fail/warning/pending/skip
|
||||
actual_value = Column(Text)
|
||||
checked_at = Column(TIMESTAMP)
|
||||
checked_by = Column(String(100))
|
||||
auto_checked = Column(Boolean, default=False)
|
||||
remarks = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="checklist_results")
|
||||
checklist_item = relationship("InstallationChecklistItem", back_populates="results")
|
||||
|
||||
|
||||
class InstallationStep(Base):
|
||||
"""安裝步驟定義(系統級)"""
|
||||
__tablename__ = "installation_steps"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
step_code = Column(String(50), unique=True, nullable=False)
|
||||
step_name = Column(String(200), nullable=False)
|
||||
phase = Column(String(20), nullable=False) # phase1/phase2/...
|
||||
sequence_order = Column(Integer, nullable=False)
|
||||
description = Column(Text)
|
||||
execution_type = Column(String(50)) # auto/manual/script
|
||||
execution_script = Column(Text)
|
||||
depends_on_steps = Column(ARRAY(String)) # 依賴的步驟代碼
|
||||
is_required = Column(Boolean, default=True)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
logs = relationship("InstallationLog", back_populates="step")
|
||||
|
||||
|
||||
class InstallationLog(Base):
|
||||
"""安裝執行記錄(租戶級)"""
|
||||
__tablename__ = "installation_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
step_id = Column(Integer, ForeignKey("installation_steps.id", ondelete="CASCADE"), nullable=False)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
|
||||
status = Column(String(20), nullable=False) # pending/running/success/failed/skipped
|
||||
started_at = Column(TIMESTAMP)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
executed_by = Column(String(100))
|
||||
execution_method = Column(String(50)) # manual/auto/api/script
|
||||
result_data = Column(JSONB)
|
||||
error_message = Column(Text)
|
||||
retry_count = Column(Integer, default=0)
|
||||
remarks = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
step = relationship("InstallationStep", back_populates="logs")
|
||||
session = relationship("InstallationSession", back_populates="installation_logs")
|
||||
|
||||
|
||||
class InstallationTenantInfo(Base):
|
||||
"""租戶初始化資訊"""
|
||||
__tablename__ = "installation_tenant_info"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True, unique=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
|
||||
|
||||
# 公司基本資訊
|
||||
company_name = Column(String(200))
|
||||
company_name_en = Column(String(200))
|
||||
tenant_code = Column(String(50)) # 租戶代碼 = Keycloak Realm
|
||||
tenant_prefix = Column(String(10)) # 員工編號前綴
|
||||
tax_id = Column(String(50))
|
||||
industry = Column(String(100))
|
||||
company_size = Column(String(20)) # small/medium/large
|
||||
|
||||
# 聯絡資訊
|
||||
tel = Column(String(20)) # 公司電話(對應 tenants.tel)
|
||||
phone = Column(String(50))
|
||||
fax = Column(String(50))
|
||||
email = Column(String(200))
|
||||
website = Column(String(200))
|
||||
add = Column(Text) # 公司地址(對應 tenants.add)
|
||||
address = Column(Text)
|
||||
address_en = Column(Text)
|
||||
|
||||
# 郵件網域設定
|
||||
domain_set = Column(Integer, default=2) # 1=組織網域, 2=部門網域
|
||||
domain = Column(String(100)) # 組織網域(domain_set=1 時使用)
|
||||
|
||||
# 負責人資訊
|
||||
representative_name = Column(String(100))
|
||||
representative_title = Column(String(100))
|
||||
representative_email = Column(String(200))
|
||||
representative_phone = Column(String(50))
|
||||
|
||||
# 系統管理員資訊
|
||||
admin_employee_id = Column(String(50))
|
||||
admin_username = Column(String(100))
|
||||
admin_legal_name = Column(String(100))
|
||||
admin_english_name = Column(String(100))
|
||||
admin_email = Column(String(200))
|
||||
admin_phone = Column(String(50))
|
||||
|
||||
# 初始設定
|
||||
default_language = Column(String(10), default='zh-TW')
|
||||
timezone = Column(String(50), default='Asia/Taipei')
|
||||
date_format = Column(String(20), default='YYYY-MM-DD')
|
||||
currency = Column(String(10), default='TWD')
|
||||
|
||||
# 狀態追蹤
|
||||
is_completed = Column(Boolean, default=False)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
completed_by = Column(String(100))
|
||||
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="tenant_info")
|
||||
|
||||
|
||||
class InstallationDepartmentSetup(Base):
|
||||
"""部門架構設定"""
|
||||
__tablename__ = "installation_department_setup"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
|
||||
department_code = Column(String(50), nullable=False)
|
||||
department_name = Column(String(200), nullable=False)
|
||||
department_name_en = Column(String(200))
|
||||
email_domain = Column(String(100))
|
||||
parent_code = Column(String(50))
|
||||
depth = Column(Integer, default=0)
|
||||
manager_name = Column(String(100))
|
||||
is_created = Column(Boolean, default=False)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="department_setups")
|
||||
|
||||
|
||||
class TemporaryPassword(Base):
|
||||
"""臨時密碼"""
|
||||
__tablename__ = "temporary_passwords"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
employee_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 employees 表可能不存在
|
||||
username = Column(String(100), nullable=False)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
|
||||
|
||||
# 密碼資訊
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
plain_password = Column(String(100)) # 明文密碼(僅初始化階段)
|
||||
password_method = Column(String(20)) # auto/manual
|
||||
is_temporary = Column(Boolean, default=True)
|
||||
must_change_on_login = Column(Boolean, default=True)
|
||||
|
||||
# 有效期限
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
expires_at = Column(TIMESTAMP)
|
||||
|
||||
# 使用狀態
|
||||
is_used = Column(Boolean, default=False)
|
||||
used_at = Column(TIMESTAMP)
|
||||
first_login_at = Column(TIMESTAMP)
|
||||
password_changed_at = Column(TIMESTAMP)
|
||||
|
||||
# 查看控制
|
||||
is_viewable = Column(Boolean, default=True)
|
||||
viewable_until = Column(TIMESTAMP)
|
||||
view_count = Column(Integer, default=0)
|
||||
last_viewed_at = Column(TIMESTAMP)
|
||||
first_viewed_at = Column(TIMESTAMP)
|
||||
|
||||
# 明文密碼清除記錄
|
||||
plain_password_cleared_at = Column(TIMESTAMP)
|
||||
cleared_reason = Column(String(100))
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant 和 employee relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="temporary_passwords")
|
||||
|
||||
|
||||
class InstallationAccessLog(Base):
|
||||
"""存取審計日誌"""
|
||||
__tablename__ = "installation_access_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"), nullable=False)
|
||||
action = Column(String(50), nullable=False) # lock/unlock/view/download_pdf
|
||||
action_by = Column(String(100))
|
||||
action_method = Column(String(50)) # database/api/system
|
||||
ip_address = Column(String(50))
|
||||
user_agent = Column(Text)
|
||||
access_granted = Column(Boolean)
|
||||
deny_reason = Column(String(200))
|
||||
sensitive_data_accessed = Column(ARRAY(String))
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
session = relationship("InstallationSession", back_populates="access_logs")
|
||||
|
||||
|
||||
class InstallationEnvironmentConfig(Base):
|
||||
"""環境配置記錄"""
|
||||
__tablename__ = "installation_environment_config"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
|
||||
config_key = Column(String(100), unique=True, nullable=False, index=True)
|
||||
config_value = Column(Text)
|
||||
config_category = Column(String(50), nullable=False, index=True) # redis/database/keycloak/mailserver/nextcloud/traefik
|
||||
is_sensitive = Column(Boolean, default=False) # 是否為敏感資訊(密碼等)
|
||||
is_configured = Column(Boolean, default=False)
|
||||
configured_at = Column(TIMESTAMP)
|
||||
configured_by = Column(String(100))
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
session = relationship("InstallationSession")
|
||||
|
||||
|
||||
class InstallationSystemStatus(Base):
|
||||
"""系統狀態記錄(三階段:Initialization/Operational/Transition)"""
|
||||
__tablename__ = "installation_system_status"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
current_phase = Column(String(20), nullable=False, index=True) # initialization/operational/transition
|
||||
previous_phase = Column(String(20))
|
||||
phase_changed_at = Column(TIMESTAMP)
|
||||
phase_changed_by = Column(String(100))
|
||||
phase_change_reason = Column(Text)
|
||||
|
||||
# Initialization 階段資訊
|
||||
initialized_at = Column(TIMESTAMP)
|
||||
initialized_by = Column(String(100))
|
||||
initialization_completed = Column(Boolean, default=False)
|
||||
|
||||
# Operational 階段資訊
|
||||
last_health_check_at = Column(TIMESTAMP)
|
||||
health_check_status = Column(String(20)) # healthy/degraded/unhealthy
|
||||
operational_since = Column(TIMESTAMP)
|
||||
|
||||
# Transition 階段資訊
|
||||
transition_started_at = Column(TIMESTAMP)
|
||||
transition_approved_by = Column(String(100))
|
||||
env_db_consistent = Column(Boolean)
|
||||
consistency_checked_at = Column(TIMESTAMP)
|
||||
inconsistencies = Column(Text) # JSON 格式
|
||||
|
||||
# 系統鎖定
|
||||
is_locked = Column(Boolean, default=False)
|
||||
locked_at = Column(TIMESTAMP)
|
||||
locked_by = Column(String(100))
|
||||
lock_reason = Column(String(200))
|
||||
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
107
backend/app/models/invoice.py
Normal file
107
backend/app/models/invoice.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
發票記錄 Model
|
||||
管理租戶的帳單和發票
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InvoiceStatus(str, enum.Enum):
|
||||
"""發票狀態"""
|
||||
PENDING = "pending" # 待付款
|
||||
PAID = "paid" # 已付款
|
||||
OVERDUE = "overdue" # 逾期未付
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
|
||||
|
||||
class Invoice(Base):
|
||||
"""發票記錄表"""
|
||||
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 發票資訊
|
||||
invoice_number = Column(String(50), unique=True, nullable=False, index=True, comment="發票號碼 (INV-2026-03-001)")
|
||||
issue_date = Column(Date, nullable=False, comment="開立日期")
|
||||
due_date = Column(Date, nullable=False, comment="到期日")
|
||||
|
||||
# 金額
|
||||
amount = Column(Numeric(10, 2), nullable=False, comment="金額 (未稅)")
|
||||
tax = Column(Numeric(10, 2), default=0, nullable=False, comment="稅額")
|
||||
total = Column(Numeric(10, 2), nullable=False, comment="總計 (含稅)")
|
||||
|
||||
# 狀態
|
||||
status = Column(String(20), default=InvoiceStatus.PENDING, nullable=False, comment="狀態")
|
||||
|
||||
# 付款資訊
|
||||
paid_at = Column(DateTime, nullable=True, comment="付款時間")
|
||||
payment_method = Column(String(20), nullable=True, comment="付款方式 (credit_card/wire_transfer)")
|
||||
|
||||
# 發票明細 (JSON 格式)
|
||||
line_items = Column(JSONB, nullable=True, comment="發票明細")
|
||||
# 範例:
|
||||
# [
|
||||
# {"description": "標準方案 (20 人)", "quantity": 1, "unit_price": 10000, "amount": 10000},
|
||||
# {"description": "超額用戶 (2 人)", "quantity": 2, "unit_price": 500, "amount": 1000}
|
||||
# ]
|
||||
|
||||
# PDF 檔案
|
||||
pdf_path = Column(String(200), nullable=True, comment="發票 PDF 路徑")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
payments = relationship(
|
||||
"Payment",
|
||||
back_populates="invoice",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Invoice {self.invoice_number} - NT$ {self.total} ({self.status})>"
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
"""是否已付款"""
|
||||
return self.status == InvoiceStatus.PAID
|
||||
|
||||
@property
|
||||
def is_overdue(self) -> bool:
|
||||
"""是否逾期"""
|
||||
return (
|
||||
self.status in [InvoiceStatus.PENDING, InvoiceStatus.OVERDUE] and
|
||||
date.today() > self.due_date
|
||||
)
|
||||
|
||||
@property
|
||||
def days_overdue(self) -> int:
|
||||
"""逾期天數"""
|
||||
if not self.is_overdue:
|
||||
return 0
|
||||
return (date.today() - self.due_date).days
|
||||
|
||||
def mark_as_paid(self, payment_method: str = None):
|
||||
"""標記為已付款"""
|
||||
self.status = InvoiceStatus.PAID
|
||||
self.paid_at = datetime.utcnow()
|
||||
if payment_method:
|
||||
self.payment_method = payment_method
|
||||
|
||||
@classmethod
|
||||
def generate_invoice_number(cls, year: int, month: int, sequence: int) -> str:
|
||||
"""生成發票號碼"""
|
||||
return f"INV-{year:04d}-{month:02d}-{sequence:03d}"
|
||||
68
backend/app/models/network_drive.py
Normal file
68
backend/app/models/network_drive.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
網路硬碟 Model
|
||||
一個員工對應一個 NAS 帳號
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class NetworkDrive(Base):
|
||||
"""網路硬碟表"""
|
||||
|
||||
__tablename__ = "tenant_network_drives"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", name="uq_network_drive_employee"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
|
||||
# 一個員工只有一個 NAS 帳號
|
||||
drive_name = Column(String(100), unique=True, nullable=False, comment="NAS 帳號名稱 (與 username_base 相同)")
|
||||
quota_gb = Column(Integer, nullable=False, comment="配額 (GB),取所有身份中的最高職級")
|
||||
|
||||
# 訪問路徑
|
||||
webdav_url = Column(String(255), comment="WebDAV 路徑")
|
||||
smb_url = Column(String(255), comment="SMB 路徑")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="network_drive")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDrive {self.drive_name} - {self.quota_gb}GB>"
|
||||
|
||||
@property
|
||||
def webdav_path(self) -> str:
|
||||
"""WebDAV 完整路徑"""
|
||||
return self.webdav_url or f"https://nas.lab.taipei/webdav/{self.drive_name}"
|
||||
|
||||
@property
|
||||
def smb_path(self) -> str:
|
||||
"""SMB 完整路徑"""
|
||||
return self.smb_url or f"\\\\10.1.0.30\\{self.drive_name}"
|
||||
|
||||
def update_quota_from_job_level(self, job_level: str) -> None:
|
||||
"""根據職級更新配額"""
|
||||
from app.core.config import settings
|
||||
|
||||
quota_mapping = {
|
||||
"Junior": settings.NAS_QUOTA_JUNIOR,
|
||||
"Mid": settings.NAS_QUOTA_MID,
|
||||
"Senior": settings.NAS_QUOTA_SENIOR,
|
||||
"Manager": settings.NAS_QUOTA_MANAGER,
|
||||
}
|
||||
|
||||
new_quota = quota_mapping.get(job_level, settings.NAS_QUOTA_JUNIOR)
|
||||
|
||||
# 只在配額增加時更新 (不降低配額)
|
||||
if new_quota > self.quota_gb:
|
||||
self.quota_gb = new_quota
|
||||
51
backend/app/models/payment.py
Normal file
51
backend/app/models/payment.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
付款記錄 Model
|
||||
記錄所有付款交易
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PaymentStatus(str, enum.Enum):
|
||||
"""付款狀態"""
|
||||
SUCCESS = "success" # 成功
|
||||
FAILED = "failed" # 失敗
|
||||
PENDING = "pending" # 處理中
|
||||
REFUNDED = "refunded" # 已退款
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
"""付款記錄表"""
|
||||
|
||||
__tablename__ = "payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
invoice_id = Column(Integer, ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 付款資訊
|
||||
amount = Column(Numeric(10, 2), nullable=False, comment="付款金額")
|
||||
payment_method = Column(String(20), nullable=False, comment="付款方式 (credit_card/wire_transfer/cash)")
|
||||
transaction_id = Column(String(100), nullable=True, comment="金流交易編號")
|
||||
status = Column(String(20), default=PaymentStatus.PENDING, nullable=False, comment="狀態")
|
||||
|
||||
# 時間記錄
|
||||
paid_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="付款時間")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 關聯
|
||||
invoice = relationship("Invoice", back_populates="payments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Payment NT$ {self.amount} - {self.status}>"
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
"""是否付款成功"""
|
||||
return self.status == PaymentStatus.SUCCESS
|
||||
112
backend/app/models/permission.py
Normal file
112
backend/app/models/permission.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
系統權限 Model
|
||||
管理員工在各系統的存取權限 (Gitea, Portainer, etc.)
|
||||
符合設計文件規範: HR Portal設計文件.md
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""系統權限表"""
|
||||
|
||||
__tablename__ = "tenant_permissions"
|
||||
__table_args__ = (
|
||||
# 同一員工在同一系統只能有一個權限記錄
|
||||
UniqueConstraint("employee_id", "system_name", name="uq_employee_system"),
|
||||
# 索引
|
||||
Index("idx_permissions_employee", "employee_id"),
|
||||
Index("idx_permissions_tenant", "tenant_id"),
|
||||
Index("idx_permissions_system", "system_name"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租戶 ID"
|
||||
)
|
||||
employee_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="員工 ID"
|
||||
)
|
||||
|
||||
# 權限設定
|
||||
system_name = Column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="系統名稱 (gitea, portainer, traefik, keycloak)"
|
||||
)
|
||||
access_level = Column(
|
||||
String(50),
|
||||
default='user',
|
||||
nullable=False,
|
||||
comment="存取層級 (admin/user/readonly)"
|
||||
)
|
||||
|
||||
# 授予資訊
|
||||
granted_at = Column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
comment="授予時間"
|
||||
)
|
||||
granted_by = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="授予人 (員工 ID)"
|
||||
)
|
||||
|
||||
# 通用欄位 (Note: Permission 表不需要 is_active,依靠 granted_at 判斷)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[employee_id],
|
||||
back_populates="permissions"
|
||||
)
|
||||
granted_by_employee = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[granted_by]
|
||||
)
|
||||
granter = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[granted_by],
|
||||
viewonly=True,
|
||||
)
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission {self.system_name}:{self.access_level}>"
|
||||
|
||||
@classmethod
|
||||
def get_available_systems(cls) -> list[str]:
|
||||
"""取得可用的系統清單"""
|
||||
return [
|
||||
"gitea", # Git 代碼託管
|
||||
"portainer", # 容器管理
|
||||
"traefik", # 反向代理管理
|
||||
"keycloak", # SSO 管理
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_available_access_levels(cls) -> list[str]:
|
||||
"""取得可用的存取層級"""
|
||||
return [
|
||||
"admin", # 管理員 (完整控制)
|
||||
"user", # 一般使用者
|
||||
"readonly", # 唯讀
|
||||
]
|
||||
31
backend/app/models/personal_service.py
Normal file
31
backend/app/models/personal_service.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
個人化服務 Model
|
||||
定義可為員工啟用的個人服務(SSO、Email、Calendar、Drive、Office)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PersonalService(Base):
|
||||
"""個人化服務表"""
|
||||
|
||||
__tablename__ = "personal_services"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("service_code", name="uq_personal_service_code"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
service_code = Column(String(20), unique=True, nullable=False, comment="服務代碼: SSO/Email/Calendar/Drive/Office")
|
||||
service_name = Column(String(100), nullable=False, comment="服務名稱")
|
||||
description = Column(String(500), nullable=True, comment="服務說明")
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
|
||||
# 通用欄位
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PersonalService {self.service_code} - {self.service_name}>"
|
||||
120
backend/app/models/role.py
Normal file
120
backend/app/models/role.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
RBAC 角色相關 Models
|
||||
- UserRole: 租戶層級角色 (不綁定部門)
|
||||
- RoleRight: 角色對系統功能的 CRUD 權限
|
||||
- UserRoleAssignment: 使用者角色分配 (直接對人,跨部門有效)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""角色表 (租戶層級,不綁定部門)"""
|
||||
|
||||
__tablename__ = "tenant_user_roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_role_seq"),
|
||||
UniqueConstraint("tenant_id", "role_code", name="uq_tenant_role_code"),
|
||||
Index("idx_roles_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
role_code = Column(String(100), nullable=False, comment="角色代碼 (租戶內唯一,例如 HR_ADMIN)")
|
||||
role_name = Column(String(200), nullable=False, comment="角色名稱")
|
||||
description = Column(Text, nullable=True, comment="角色說明")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="user_roles")
|
||||
rights = relationship("RoleRight", back_populates="role", cascade="all, delete-orphan", lazy="selectin")
|
||||
user_assignments = relationship("UserRoleAssignment", back_populates="role", cascade="all, delete-orphan",
|
||||
lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole {self.role_code} - {self.role_name}>"
|
||||
|
||||
|
||||
class RoleRight(Base):
|
||||
"""角色功能權限表 (Role and System Right)"""
|
||||
|
||||
__tablename__ = "tenant_role_rights"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role_id", "function_id", name="uq_role_function"),
|
||||
Index("idx_role_rights_role", "role_id"),
|
||||
Index("idx_role_rights_function", "function_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="角色 ID")
|
||||
function_id = Column(Integer, ForeignKey("system_functions_cache.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="系統功能 ID")
|
||||
can_read = Column(Boolean, default=False, nullable=False, comment="查詢權限")
|
||||
can_create = Column(Boolean, default=False, nullable=False, comment="新增權限")
|
||||
can_update = Column(Boolean, default=False, nullable=False, comment="修改權限")
|
||||
can_delete = Column(Boolean, default=False, nullable=False, comment="刪除權限")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
role = relationship("UserRole", back_populates="rights")
|
||||
function = relationship("SystemFunctionCache")
|
||||
|
||||
def __repr__(self):
|
||||
perms = []
|
||||
if self.can_read: perms.append("R")
|
||||
if self.can_create: perms.append("C")
|
||||
if self.can_update: perms.append("U")
|
||||
if self.can_delete: perms.append("D")
|
||||
return f"<RoleRight role={self.role_id} fn={self.function_id} [{','.join(perms)}]>"
|
||||
|
||||
|
||||
class UserRoleAssignment(Base):
|
||||
"""使用者角色分配表 (直接對人,跨部門有效)"""
|
||||
|
||||
__tablename__ = "tenant_user_role_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("keycloak_user_id", "role_id", name="uq_user_role"),
|
||||
Index("idx_user_roles_tenant", "tenant_id"),
|
||||
Index("idx_user_roles_keycloak", "keycloak_user_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID (永久識別碼)")
|
||||
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="角色 ID")
|
||||
|
||||
# 審計欄位(完整記錄)
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="分配時間")
|
||||
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
|
||||
revoked_at = Column(DateTime, nullable=True, comment="撤銷時間(軟刪除)")
|
||||
revoked_by = Column(String(36), nullable=True, comment="撤銷者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
role = relationship("UserRole", back_populates="user_assignments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRoleAssignment user={self.keycloak_user_id} role={self.role_id}>"
|
||||
77
backend/app/models/subscription.py
Normal file
77
backend/app/models/subscription.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
訂閱記錄 Model
|
||||
管理租戶的訂閱狀態和歷史
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Boolean, ForeignKey, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SubscriptionStatus(str, enum.Enum):
|
||||
"""訂閱狀態"""
|
||||
ACTIVE = "active" # 進行中
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
EXPIRED = "expired" # 已過期
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
"""訂閱記錄表"""
|
||||
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 方案資訊
|
||||
plan_id = Column(String(50), nullable=False, comment="方案 ID (starter/standard/enterprise)")
|
||||
start_date = Column(Date, nullable=False, comment="開始日期")
|
||||
end_date = Column(Date, nullable=False, comment="結束日期")
|
||||
status = Column(String(20), default=SubscriptionStatus.ACTIVE, nullable=False, comment="狀態")
|
||||
|
||||
# 自動續約
|
||||
auto_renew = Column(Boolean, default=True, nullable=False, comment="是否自動續約")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
cancelled_at = Column(DateTime, nullable=True, comment="取消時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="subscriptions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription {self.plan_id} for Tenant#{self.tenant_id} ({self.start_date} ~ {self.end_date})>"
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""是否為活躍訂閱"""
|
||||
today = date.today()
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE and
|
||||
self.start_date <= today <= self.end_date
|
||||
)
|
||||
|
||||
@property
|
||||
def days_remaining(self) -> int:
|
||||
"""剩餘天數"""
|
||||
if not self.is_active:
|
||||
return 0
|
||||
return (self.end_date - date.today()).days
|
||||
|
||||
def renew(self, months: int = 1) -> "Subscription":
|
||||
"""續約 (創建新的訂閱記錄)"""
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
new_start = self.end_date + relativedelta(days=1)
|
||||
new_end = new_start + relativedelta(months=months) - relativedelta(days=1)
|
||||
|
||||
return Subscription(
|
||||
tenant_id=self.tenant_id,
|
||||
plan_id=self.plan_id,
|
||||
start_date=new_start,
|
||||
end_date=new_end,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
auto_renew=self.auto_renew
|
||||
)
|
||||
111
backend/app/models/system_function.py
Normal file
111
backend/app/models/system_function.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
SystemFunction Model
|
||||
系統功能明細檔
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemFunction(Base):
|
||||
"""系統功能明細"""
|
||||
__tablename__ = "system_functions"
|
||||
|
||||
# 1. 資料編號 (PK, 自動編號從 10 開始, 1~9 為功能設定編號)
|
||||
id = Column(Integer, primary_key=True, index=True, comment="資料編號")
|
||||
|
||||
# 2. 系統功能代碼/功能英文名稱
|
||||
code = Column(String(200), nullable=False, index=True, comment="系統功能代碼/功能英文名稱")
|
||||
|
||||
# 3. 上層功能代碼 (0 為初始層)
|
||||
upper_function_id = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
index=True,
|
||||
comment="上層功能代碼 (0為初始層)"
|
||||
)
|
||||
|
||||
# 4. 系統功能中文名稱
|
||||
name = Column(String(200), nullable=False, comment="系統功能中文名稱")
|
||||
|
||||
# 5. 系統功能類型 (1:node, 2:function)
|
||||
function_type = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="系統功能類型 (1:node, 2:function)"
|
||||
)
|
||||
|
||||
# 6. 系統功能次序
|
||||
order = Column(Integer, nullable=False, comment="系統功能次序")
|
||||
|
||||
# 7. 功能圖示
|
||||
function_icon = Column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
comment="功能圖示"
|
||||
)
|
||||
|
||||
# 8. 功能模組名稱 (function_type=2 必填)
|
||||
module_code = Column(
|
||||
String(200),
|
||||
nullable=True,
|
||||
comment="功能模組名稱 (function_type=2 必填)"
|
||||
)
|
||||
|
||||
# 9. 模組項目 (JSON: [View, Create, Read, Update, Delete, Print, File])
|
||||
module_functions = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
server_default="[]",
|
||||
comment="模組項目 (View,Create,Read,Update,Delete,Print,File)"
|
||||
)
|
||||
|
||||
# 10. 說明 (富文本格式)
|
||||
description = Column(
|
||||
Text,
|
||||
nullable=False,
|
||||
server_default="",
|
||||
comment="說明 (富文本格式)"
|
||||
)
|
||||
|
||||
# 11. 系統管理
|
||||
is_mana = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="true",
|
||||
comment="系統管理"
|
||||
)
|
||||
|
||||
# 12. 啟用
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="true",
|
||||
index=True,
|
||||
comment="啟用"
|
||||
)
|
||||
|
||||
# 13. 資料建立者
|
||||
edit_by = Column(Integer, nullable=False, comment="資料建立者")
|
||||
|
||||
# 14. 資料最新建立時間
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="資料最新建立時間"
|
||||
)
|
||||
|
||||
# 15. 資料最新修改時間
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
onupdate=func.now(),
|
||||
comment="資料最新修改時間"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemFunction(id={self.id}, code={self.code}, name={self.name})>"
|
||||
31
backend/app/models/system_function_cache.py
Normal file
31
backend/app/models/system_function_cache.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
系統功能快取 Model
|
||||
從 System Admin 服務同步的系統功能定義 (只讀副本)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint, Index
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemFunctionCache(Base):
|
||||
"""系統功能快取表"""
|
||||
|
||||
__tablename__ = "system_functions_cache"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("function_code", name="uq_function_code"),
|
||||
Index("idx_func_cache_service", "service_code"),
|
||||
Index("idx_func_cache_category", "function_category"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, comment="與 System Admin 的 id 一致")
|
||||
service_code = Column(String(50), nullable=False, comment="服務代碼: hr/erp/mail/ai")
|
||||
function_code = Column(String(100), nullable=False, comment="功能代碼: HR_EMPLOYEE_VIEW")
|
||||
function_name = Column(String(200), nullable=False, comment="功能名稱")
|
||||
function_category = Column(String(50), nullable=True,
|
||||
comment="功能分類: query/manage/approve/report")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
synced_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="最後同步時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemFunctionCache {self.function_code} ({self.service_code})>"
|
||||
114
backend/app/models/tenant.py
Normal file
114
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
租戶 Model
|
||||
多租戶 SaaS 的核心 - 每個客戶公司對應一個租戶
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class TenantStatus(str, enum.Enum):
|
||||
"""租戶狀態"""
|
||||
TRIAL = "trial" # 試用中
|
||||
ACTIVE = "active" # 正常使用
|
||||
SUSPENDED = "suspended" # 暫停 (逾期未付款)
|
||||
DELETED = "deleted" # 已刪除
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""租戶表 (客戶組織)"""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
# 基本欄位
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True, comment="租戶代碼 (英文,例如 porscheworld)")
|
||||
name = Column(String(200), nullable=False, comment="公司名稱")
|
||||
name_eng = Column(String(200), nullable=True, comment="公司英文名稱")
|
||||
|
||||
# SSO 整合
|
||||
keycloak_realm = Column(String(100), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak Realm 名稱 (等同 code,每個組織一個獨立 Realm)")
|
||||
|
||||
# 公司資訊
|
||||
tax_id = Column(String(20), nullable=True, comment="統一編號")
|
||||
prefix = Column(String(10), nullable=False, default="ORG", comment="員工編號前綴 (例如 PWD → PWD0001)")
|
||||
domain = Column(String(100), nullable=True, comment="主網域 (例如 porscheworld.tw)")
|
||||
domain_set = Column(Text, nullable=True, comment="網域集合 (JSON Array,例如 [\"ease.taipei\", \"lab.taipei\"])")
|
||||
tel = Column(String(50), nullable=True, comment="公司電話")
|
||||
add = Column(String(500), nullable=True, comment="公司地址")
|
||||
url = Column(String(200), nullable=True, comment="公司網站")
|
||||
|
||||
# 訂閱與方案
|
||||
plan_id = Column(String(50), nullable=False, default="starter", comment="方案 ID (starter/standard/enterprise)")
|
||||
max_users = Column(Integer, nullable=False, default=5, comment="最大用戶數")
|
||||
storage_quota_gb = Column(Integer, nullable=False, default=100, comment="總儲存配額 (GB)")
|
||||
|
||||
# 狀態管理
|
||||
status = Column(String(20), default=TenantStatus.TRIAL, nullable=False, comment="狀態")
|
||||
is_sysmana = Column(Boolean, default=False, nullable=False, comment="是否為系統管理公司 (管理其他租戶)")
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
|
||||
# 初始化狀態
|
||||
is_initialized = Column(Boolean, default=False, nullable=False, comment="是否已完成初始化設定")
|
||||
initialized_at = Column(DateTime, nullable=True, comment="初始化完成時間")
|
||||
initialized_by = Column(String(255), nullable=True, comment="執行初始化的使用者名稱")
|
||||
|
||||
# 時間記錄(通用欄位)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
departments = relationship(
|
||||
"Department",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
employees = relationship(
|
||||
"Employee",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
user_roles = relationship(
|
||||
"UserRole",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.code} - {self.name}>"
|
||||
|
||||
# is_active 已改為資料庫欄位,移除 @property
|
||||
|
||||
@property
|
||||
def is_trial(self) -> bool:
|
||||
"""是否為試用狀態"""
|
||||
return self.status == TenantStatus.TRIAL
|
||||
|
||||
@property
|
||||
def total_users(self) -> int:
|
||||
"""總用戶數"""
|
||||
return self.employees.count()
|
||||
|
||||
@property
|
||||
def is_over_user_limit(self) -> bool:
|
||||
"""是否超過用戶數限制"""
|
||||
return self.total_users > self.max_users
|
||||
|
||||
@property
|
||||
def domains(self):
|
||||
"""網域列表(從 domain_set JSON 解析)"""
|
||||
if not self.domain_set:
|
||||
return []
|
||||
import json
|
||||
try:
|
||||
return json.loads(self.domain_set)
|
||||
except:
|
||||
return []
|
||||
119
backend/app/models/tenant_domain.py
Normal file
119
backend/app/models/tenant_domain.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
租戶網域 Model
|
||||
支援單一租戶使用多個網域 (多品牌/國際化)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DomainStatus(str, enum.Enum):
|
||||
"""網域狀態"""
|
||||
PENDING = "pending" # 待驗證
|
||||
ACTIVE = "active" # 啟用中
|
||||
DISABLED = "disabled" # 已停用
|
||||
|
||||
|
||||
class TenantDomain(Base):
|
||||
"""租戶網域表 (一個租戶可以有多個網域)"""
|
||||
|
||||
__tablename__ = "tenant_domains"
|
||||
__table_args__ = (
|
||||
# 每個租戶只能有一個主要網域
|
||||
Index("idx_tenant_primary_domain", "tenant_id", unique=True, postgresql_where=Column("is_primary") == True),
|
||||
# 一般索引
|
||||
Index("idx_tenant_domains_tenant", "tenant_id"),
|
||||
Index("idx_tenant_domains_status", "status"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
domain = Column(String(100), unique=True, nullable=False, index=True, comment="網域名稱 (abc.com.tw)")
|
||||
|
||||
# 網域屬性
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要網域")
|
||||
status = Column(String(20), default=DomainStatus.PENDING, nullable=False, comment="狀態")
|
||||
verified = Column(Boolean, default=False, nullable=False, comment="DNS 驗證狀態")
|
||||
|
||||
# DNS 驗證
|
||||
verification_token = Column(String(100), nullable=True, comment="驗證 Token")
|
||||
verified_at = Column(DateTime, nullable=True, comment="驗證時間")
|
||||
|
||||
# 服務啟用狀態
|
||||
enable_email = Column(Boolean, default=True, nullable=False, comment="啟用郵件服務")
|
||||
enable_webmail = Column(Boolean, default=True, nullable=False, comment="啟用 WebMail")
|
||||
enable_drive = Column(Boolean, default=True, nullable=False, comment="啟用雲端硬碟")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="domains")
|
||||
email_aliases = relationship(
|
||||
"UserEmailAlias",
|
||||
back_populates="domain",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantDomain {self.domain} ({'主要' if self.is_primary else '次要'})>"
|
||||
|
||||
@property
|
||||
def is_verified(self) -> bool:
|
||||
"""是否已驗證"""
|
||||
return self.verified and self.status == DomainStatus.ACTIVE
|
||||
|
||||
def generate_dns_records(self) -> list:
|
||||
"""生成 DNS 驗證記錄指引"""
|
||||
records = []
|
||||
|
||||
# TXT 記錄 - 網域所有權驗證
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": "@",
|
||||
"value": f"porsche-cloud-verify={self.verification_token}",
|
||||
"purpose": "網域所有權驗證"
|
||||
})
|
||||
|
||||
if self.enable_email:
|
||||
# MX 記錄 - 郵件伺服器
|
||||
records.append({
|
||||
"type": "MX",
|
||||
"name": "@",
|
||||
"value": "mail.porschecloud.tw",
|
||||
"priority": 10,
|
||||
"purpose": "郵件伺服器"
|
||||
})
|
||||
|
||||
# SPF 記錄 - 防止郵件偽造
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": "@",
|
||||
"value": "v=spf1 include:porschecloud.tw ~all",
|
||||
"purpose": "郵件 SPF 記錄"
|
||||
})
|
||||
|
||||
if self.enable_webmail:
|
||||
# CNAME - WebMail
|
||||
records.append({
|
||||
"type": "CNAME",
|
||||
"name": "mail",
|
||||
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
|
||||
"purpose": "WebMail 訪問"
|
||||
})
|
||||
|
||||
if self.enable_drive:
|
||||
# CNAME - 雲端硬碟
|
||||
records.append({
|
||||
"type": "CNAME",
|
||||
"name": "drive",
|
||||
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
|
||||
"purpose": "雲端硬碟訪問"
|
||||
})
|
||||
|
||||
return records
|
||||
56
backend/app/models/usage_log.py
Normal file
56
backend/app/models/usage_log.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
使用量記錄 Model
|
||||
記錄租戶和用戶的資源使用情況
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UsageLog(Base):
|
||||
"""使用量記錄表 (每日統計)"""
|
||||
|
||||
__tablename__ = "usage_logs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", "date", name="uq_usage_tenant_user_date"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
date = Column(Date, nullable=False, index=True, comment="統計日期")
|
||||
|
||||
# 郵件使用量
|
||||
email_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="郵件儲存 (GB)")
|
||||
emails_sent = Column(Integer, default=0, nullable=False, comment="發送郵件數")
|
||||
emails_received = Column(Integer, default=0, nullable=False, comment="接收郵件數")
|
||||
|
||||
# 雲端硬碟使用量
|
||||
drive_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="硬碟儲存 (GB)")
|
||||
files_uploaded = Column(Integer, default=0, nullable=False, comment="上傳檔案數")
|
||||
files_downloaded = Column(Integer, default=0, nullable=False, comment="下載檔案數")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UsageLog Tenant#{self.tenant_id} User#{self.user_id} {self.date}>"
|
||||
|
||||
@property
|
||||
def total_storage_gb(self) -> float:
|
||||
"""總儲存使用量 (GB)"""
|
||||
return float(self.email_storage_gb) + float(self.drive_storage_gb)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, tenant_id: int, user_id: int = None, log_date: date = None):
|
||||
"""獲取或創建當日記錄"""
|
||||
if log_date is None:
|
||||
log_date = date.today()
|
||||
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
date=log_date
|
||||
)
|
||||
51
backend/app/models/user_email_alias.py
Normal file
51
backend/app/models/user_email_alias.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
用戶郵件別名 Model
|
||||
支援員工在不同網域擁有多個郵件地址
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserEmailAlias(Base):
|
||||
"""用戶郵件別名表 (一個用戶可以有多個郵件地址)"""
|
||||
|
||||
__tablename__ = "user_email_aliases"
|
||||
__table_args__ = (
|
||||
# 每個用戶只能有一個主要郵件
|
||||
Index("idx_user_primary_email", "user_id", unique=True, postgresql_where=Column("is_primary") == True),
|
||||
# 一般索引
|
||||
Index("idx_email_aliases_user", "user_id"),
|
||||
Index("idx_email_aliases_tenant", "tenant_id"),
|
||||
Index("idx_email_aliases_domain", "domain_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
domain_id = Column(Integer, ForeignKey("tenant_domains.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
email = Column(String(150), unique=True, nullable=False, index=True, comment="郵件地址 (sales@brand-a.com)")
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要郵件地址")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
user = relationship("Employee", back_populates="email_aliases")
|
||||
domain = relationship("TenantDomain", back_populates="email_aliases")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserEmailAlias {self.email} ({'主要' if self.is_primary else '別名'})>"
|
||||
|
||||
@property
|
||||
def local_part(self) -> str:
|
||||
"""郵件前綴 (@ 之前的部分)"""
|
||||
return self.email.split('@')[0] if '@' in self.email else self.email
|
||||
|
||||
@property
|
||||
def domain_part(self) -> str:
|
||||
"""網域部分 (@ 之後的部分)"""
|
||||
return self.email.split('@')[1] if '@' in self.email else ""
|
||||
107
backend/app/schemas/__init__.py
Normal file
107
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Schemas 模組
|
||||
匯出所有 Pydantic Schemas
|
||||
"""
|
||||
|
||||
# Base
|
||||
from app.schemas.base import (
|
||||
BaseSchema,
|
||||
TimestampSchema,
|
||||
PaginationParams,
|
||||
PaginatedResponse,
|
||||
)
|
||||
|
||||
# Employee
|
||||
from app.schemas.employee import (
|
||||
EmployeeBase,
|
||||
EmployeeCreate,
|
||||
EmployeeUpdate,
|
||||
EmployeeInDB,
|
||||
EmployeeResponse,
|
||||
EmployeeListItem,
|
||||
EmployeeDetail,
|
||||
)
|
||||
|
||||
# Business Unit
|
||||
from app.schemas.business_unit import (
|
||||
BusinessUnitBase,
|
||||
BusinessUnitCreate,
|
||||
BusinessUnitUpdate,
|
||||
BusinessUnitInDB,
|
||||
BusinessUnitResponse,
|
||||
BusinessUnitListItem,
|
||||
)
|
||||
|
||||
# Department
|
||||
from app.schemas.department import (
|
||||
DepartmentBase,
|
||||
DepartmentCreate,
|
||||
DepartmentUpdate,
|
||||
DepartmentResponse,
|
||||
DepartmentListItem,
|
||||
DepartmentTreeNode,
|
||||
)
|
||||
|
||||
# Employee Identity
|
||||
from app.schemas.employee_identity import (
|
||||
EmployeeIdentityBase,
|
||||
EmployeeIdentityCreate,
|
||||
EmployeeIdentityUpdate,
|
||||
EmployeeIdentityInDB,
|
||||
EmployeeIdentityResponse,
|
||||
EmployeeIdentityListItem,
|
||||
)
|
||||
|
||||
# Network Drive
|
||||
from app.schemas.network_drive import (
|
||||
NetworkDriveBase,
|
||||
NetworkDriveCreate,
|
||||
NetworkDriveUpdate,
|
||||
NetworkDriveInDB,
|
||||
NetworkDriveResponse,
|
||||
NetworkDriveListItem,
|
||||
NetworkDriveQuotaUpdate,
|
||||
)
|
||||
|
||||
# Audit Log
|
||||
from app.schemas.audit_log import (
|
||||
AuditLogBase,
|
||||
AuditLogCreate,
|
||||
AuditLogInDB,
|
||||
AuditLogResponse,
|
||||
AuditLogListItem,
|
||||
AuditLogFilter,
|
||||
)
|
||||
|
||||
# Email Account
|
||||
from app.schemas.email_account import (
|
||||
EmailAccountBase,
|
||||
EmailAccountCreate,
|
||||
EmailAccountUpdate,
|
||||
EmailAccountInDB,
|
||||
EmailAccountResponse,
|
||||
EmailAccountListItem,
|
||||
EmailAccountQuotaUpdate,
|
||||
)
|
||||
|
||||
# Permission
|
||||
from app.schemas.permission import (
|
||||
PermissionBase,
|
||||
PermissionCreate,
|
||||
PermissionUpdate,
|
||||
PermissionInDB,
|
||||
PermissionResponse,
|
||||
PermissionListItem,
|
||||
PermissionBatchCreate,
|
||||
PermissionFilter,
|
||||
VALID_SYSTEMS,
|
||||
VALID_ACCESS_LEVELS,
|
||||
)
|
||||
|
||||
# Response
|
||||
from app.schemas.response import (
|
||||
ResponseModel,
|
||||
ErrorResponse,
|
||||
MessageResponse,
|
||||
SuccessResponse,
|
||||
)
|
||||
107
backend/app/schemas/audit_log.py
Normal file
107
backend/app/schemas/audit_log.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
審計日誌 Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import Field, ConfigDict
|
||||
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
class AuditLogBase(BaseSchema):
|
||||
"""審計日誌基礎 Schema"""
|
||||
|
||||
action: str = Field(..., max_length=50, description="操作類型 (create/update/delete/login)")
|
||||
resource_type: str = Field(..., max_length=50, description="資源類型 (employee/identity/department)")
|
||||
resource_id: Optional[int] = Field(None, description="資源 ID")
|
||||
performed_by: str = Field(..., max_length=100, description="操作者 SSO 帳號")
|
||||
details: Optional[Dict[str, Any]] = Field(None, description="詳細變更內容")
|
||||
ip_address: Optional[str] = Field(None, max_length=45, description="IP 位址")
|
||||
|
||||
|
||||
class AuditLogCreate(AuditLogBase):
|
||||
"""創建審計日誌 Schema"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"action": "create",
|
||||
"resource_type": "employee",
|
||||
"resource_id": 1,
|
||||
"performed_by": "porsche.chen@lab.taipei",
|
||||
"details": {
|
||||
"employee_id": "EMP001",
|
||||
"legal_name": "陳保時",
|
||||
"username_base": "porsche.chen"
|
||||
},
|
||||
"ip_address": "10.1.0.245"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuditLogInDB(AuditLogBase):
|
||||
"""資料庫中的審計日誌 Schema"""
|
||||
|
||||
id: int
|
||||
performed_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AuditLogResponse(AuditLogInDB):
|
||||
"""審計日誌響應 Schema"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"action": "create",
|
||||
"resource_type": "employee",
|
||||
"resource_id": 1,
|
||||
"performed_by": "porsche.chen@lab.taipei",
|
||||
"performed_at": "2020-01-01T12:00:00",
|
||||
"details": {
|
||||
"employee_id": "EMP001",
|
||||
"legal_name": "陳保時",
|
||||
"username_base": "porsche.chen"
|
||||
},
|
||||
"ip_address": "10.1.0.245"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuditLogListItem(BaseSchema):
|
||||
"""審計日誌列表項 Schema"""
|
||||
|
||||
id: int
|
||||
action: str
|
||||
resource_type: str
|
||||
resource_id: Optional[int] = None
|
||||
performed_by: str
|
||||
performed_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AuditLogFilter(BaseSchema):
|
||||
"""審計日誌篩選參數"""
|
||||
|
||||
action: Optional[str] = None
|
||||
resource_type: Optional[str] = None
|
||||
resource_id: Optional[int] = None
|
||||
performed_by: Optional[str] = None
|
||||
start_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"action": "create",
|
||||
"resource_type": "employee",
|
||||
"start_date": "2020-01-01T00:00:00",
|
||||
"end_date": "2020-12-31T23:59:59"
|
||||
}
|
||||
}
|
||||
)
|
||||
55
backend/app/schemas/auth.py
Normal file
55
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
認證相關 Schemas
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token 響應"""
|
||||
|
||||
access_token: str = Field(..., description="Access Token")
|
||||
token_type: str = Field(default="bearer", description="Token 類型")
|
||||
expires_in: int = Field(..., description="過期時間 (秒)")
|
||||
refresh_token: Optional[str] = Field(None, description="Refresh Token")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 1800,
|
||||
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
"""用戶資訊"""
|
||||
|
||||
sub: str = Field(..., description="用戶 ID (Keycloak UUID)")
|
||||
username: str = Field(..., description="用戶名稱")
|
||||
email: str = Field(..., description="郵件地址")
|
||||
first_name: Optional[str] = Field(None, description="名字")
|
||||
last_name: Optional[str] = Field(None, description="姓氏")
|
||||
email_verified: bool = Field(False, description="郵件是否已驗證")
|
||||
tenant: Optional[Dict[str, Any]] = Field(None, description="租戶資訊")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""登入請求"""
|
||||
|
||||
username: str = Field(..., min_length=3, description="用戶名稱")
|
||||
password: str = Field(..., min_length=6, description="密碼")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"username": "porsche.chen@lab.taipei",
|
||||
"password": "your-password"
|
||||
}
|
||||
}
|
||||
)
|
||||
49
backend/app/schemas/base.py
Normal file
49
backend/app/schemas/base.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
基礎 Schema 類別
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""基礎 Schema"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True, # 支援從 ORM 模型轉換
|
||||
use_enum_values=True, # 使用 Enum 的值
|
||||
)
|
||||
|
||||
|
||||
class TimestampSchema(BaseSchema):
|
||||
"""帶時間戳的 Schema"""
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""分頁參數"""
|
||||
|
||||
page: int = 1
|
||||
page_size: int = 20
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
"""分頁響應"""
|
||||
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
89
backend/app/schemas/business_unit.py
Normal file
89
backend/app/schemas/business_unit.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
事業部 Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import Field, ConfigDict
|
||||
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
class BusinessUnitBase(BaseSchema):
|
||||
"""事業部基礎 Schema"""
|
||||
|
||||
name: str = Field(..., min_length=2, max_length=100, description="事業部名稱")
|
||||
name_en: Optional[str] = Field(None, max_length=100, description="英文名稱")
|
||||
code: str = Field(..., min_length=2, max_length=20, description="事業部代碼")
|
||||
email_domain: str = Field(..., description="郵件網域")
|
||||
description: Optional[str] = Field(None, description="說明")
|
||||
|
||||
|
||||
class BusinessUnitCreate(BusinessUnitBase):
|
||||
"""創建事業部 Schema"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "業務發展部",
|
||||
"name_en": "Business Development",
|
||||
"code": "biz",
|
||||
"email_domain": "ease.taipei",
|
||||
"description": "碳權申請諮詢、碳足跡盤查、碳權交易媒合、業務拓展"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BusinessUnitUpdate(BaseSchema):
|
||||
"""更新事業部 Schema"""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
name_en: Optional[str] = Field(None, max_length=100)
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class BusinessUnitInDB(BusinessUnitBase):
|
||||
"""資料庫中的事業部 Schema"""
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class BusinessUnitResponse(BusinessUnitInDB):
|
||||
"""事業部響應 Schema"""
|
||||
|
||||
departments_count: Optional[int] = Field(None, description="部門數量")
|
||||
employees_count: Optional[int] = Field(None, description="員工數量")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"name": "業務發展部",
|
||||
"name_en": "Business Development",
|
||||
"code": "biz",
|
||||
"email_domain": "ease.taipei",
|
||||
"description": "碳權申請諮詢、碳足跡盤查、碳權交易媒合、業務拓展",
|
||||
"is_active": True,
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
"departments_count": 3,
|
||||
"employees_count": 15
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BusinessUnitListItem(BaseSchema):
|
||||
"""事業部列表項 Schema"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
email_domain: str
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
125
backend/app/schemas/department.py
Normal file
125
backend/app/schemas/department.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
部門 Schemas (統一樹狀結構)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import Field, ConfigDict
|
||||
|
||||
from app.schemas.base import BaseSchema
|
||||
|
||||
|
||||
class DepartmentBase(BaseSchema):
|
||||
"""部門基礎 Schema"""
|
||||
|
||||
name: str = Field(..., min_length=2, max_length=100, description="部門名稱")
|
||||
name_en: Optional[str] = Field(None, max_length=100, description="英文名稱")
|
||||
code: str = Field(..., min_length=1, max_length=20, description="部門代碼")
|
||||
description: Optional[str] = Field(None, description="說明")
|
||||
|
||||
|
||||
class DepartmentCreate(DepartmentBase):
|
||||
"""創建部門 Schema
|
||||
|
||||
- parent_id=NULL: 建立第一層部門,可設定 email_domain
|
||||
- parent_id=有值: 建立子部門,不可設定 email_domain (繼承)
|
||||
"""
|
||||
|
||||
parent_id: Optional[int] = Field(None, description="上層部門 ID (NULL=第一層)")
|
||||
email_domain: Optional[str] = Field(None, max_length=100,
|
||||
description="郵件網域 (只有第一層可設定,例如 ease.taipei)")
|
||||
email_address: Optional[str] = Field(None, max_length=255, description="部門信箱")
|
||||
email_quota_mb: Optional[int] = Field(5120, description="部門信箱配額 (MB)")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"parent_id": None,
|
||||
"name": "業務發展部",
|
||||
"name_en": "Business Development",
|
||||
"code": "BD",
|
||||
"email_domain": "ease.taipei",
|
||||
"description": "業務發展相關部門"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DepartmentUpdate(BaseSchema):
|
||||
"""更新部門 Schema
|
||||
|
||||
注意: code 和 parent_id 建立後不可修改
|
||||
email_domain 只有第一層 (depth=0) 可更新
|
||||
"""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
name_en: Optional[str] = Field(None, max_length=100)
|
||||
description: Optional[str] = None
|
||||
email_domain: Optional[str] = Field(None, max_length=100,
|
||||
description="只有第一層部門可更新")
|
||||
email_address: Optional[str] = Field(None, max_length=255)
|
||||
email_quota_mb: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class DepartmentResponse(BaseSchema):
|
||||
"""部門響應 Schema"""
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
parent_id: Optional[int] = None
|
||||
code: str
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
depth: int
|
||||
email_domain: Optional[str] = None
|
||||
effective_email_domain: Optional[str] = None
|
||||
email_address: Optional[str] = None
|
||||
email_quota_mb: int
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
is_top_level: bool = False
|
||||
created_at: datetime
|
||||
parent_name: Optional[str] = None
|
||||
member_count: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DepartmentListItem(BaseSchema):
|
||||
"""部門列表項 Schema"""
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
parent_id: Optional[int] = None
|
||||
code: str
|
||||
name: str
|
||||
depth: int
|
||||
email_domain: Optional[str] = None
|
||||
effective_email_domain: Optional[str] = None
|
||||
email_address: Optional[str] = None
|
||||
is_active: bool
|
||||
member_count: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class DepartmentTreeNode(BaseSchema):
|
||||
"""部門樹狀節點 Schema (遞迴)"""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
depth: int
|
||||
parent_id: Optional[int] = None
|
||||
email_domain: Optional[str] = None
|
||||
effective_email_domain: Optional[str] = None
|
||||
email_address: Optional[str] = None
|
||||
email_quota_mb: int
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
is_top_level: bool
|
||||
member_count: int = 0
|
||||
children: List[Any] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
126
backend/app/schemas/email_account.py
Normal file
126
backend/app/schemas/email_account.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
郵件帳號 Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, EmailStr, ConfigDict, field_validator
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class EmailAccountBase(BaseSchema):
|
||||
"""郵件帳號基礎 Schema"""
|
||||
|
||||
email_address: EmailStr = Field(..., description="郵件地址")
|
||||
quota_mb: int = Field(2048, ge=1024, le=102400, description="配額 (MB), 1GB-100GB")
|
||||
forward_to: Optional[EmailStr] = Field(None, description="轉寄地址")
|
||||
auto_reply: Optional[str] = Field(None, max_length=1000, description="自動回覆內容")
|
||||
is_active: bool = Field(True, description="是否啟用")
|
||||
|
||||
|
||||
class EmailAccountCreate(EmailAccountBase):
|
||||
"""創建郵件帳號 Schema"""
|
||||
|
||||
employee_id: int = Field(..., description="員工 ID")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"employee_id": 1,
|
||||
"email_address": "porsche.chen@porscheworld.tw",
|
||||
"quota_mb": 2048,
|
||||
"forward_to": None,
|
||||
"auto_reply": None,
|
||||
"is_active": True
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmailAccountUpdate(BaseSchema):
|
||||
"""更新郵件帳號 Schema"""
|
||||
|
||||
quota_mb: Optional[int] = Field(None, ge=1024, le=102400, description="配額 (MB)")
|
||||
forward_to: Optional[EmailStr] = Field(None, description="轉寄地址")
|
||||
auto_reply: Optional[str] = Field(None, max_length=1000, description="自動回覆內容")
|
||||
is_active: Optional[bool] = Field(None, description="是否啟用")
|
||||
|
||||
@field_validator('forward_to')
|
||||
@classmethod
|
||||
def validate_forward_to(cls, v):
|
||||
"""允許空字串來清除轉寄地址"""
|
||||
if v == "":
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('auto_reply')
|
||||
@classmethod
|
||||
def validate_auto_reply(cls, v):
|
||||
"""允許空字串來清除自動回覆"""
|
||||
if v == "":
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
class EmailAccountInDB(EmailAccountBase, TimestampSchema):
|
||||
"""資料庫中的郵件帳號 Schema"""
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
employee_id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EmailAccountResponse(EmailAccountInDB):
|
||||
"""郵件帳號響應 Schema (包含關聯資料)"""
|
||||
|
||||
employee_name: Optional[str] = Field(None, description="員工姓名")
|
||||
employee_number: Optional[str] = Field(None, description="員工編號")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"employee_id": 1,
|
||||
"email_address": "porsche.chen@porscheworld.tw",
|
||||
"quota_mb": 2048,
|
||||
"forward_to": None,
|
||||
"auto_reply": None,
|
||||
"is_active": True,
|
||||
"employee_name": "陳保時",
|
||||
"employee_number": "EMP001",
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
"updated_at": "2020-01-01T00:00:00"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmailAccountListItem(BaseSchema):
|
||||
"""郵件帳號列表項 Schema (簡化版)"""
|
||||
|
||||
id: int
|
||||
email_address: str
|
||||
quota_mb: int
|
||||
is_active: bool
|
||||
employee_id: int
|
||||
employee_name: Optional[str] = None
|
||||
employee_number: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EmailAccountQuotaUpdate(BaseSchema):
|
||||
"""郵件配額更新 Schema"""
|
||||
|
||||
quota_mb: int = Field(..., ge=1024, le=102400, description="配額 (MB), 1GB-100GB")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"quota_mb": 5120 # 5GB
|
||||
}
|
||||
}
|
||||
)
|
||||
120
backend/app/schemas/employee.py
Normal file
120
backend/app/schemas/employee.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
員工 Schemas
|
||||
"""
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field, EmailStr, ConfigDict
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
from app.models.employee import EmployeeStatus
|
||||
|
||||
|
||||
class EmployeeBase(BaseSchema):
|
||||
"""員工基礎 Schema"""
|
||||
|
||||
username_base: str = Field(..., min_length=3, max_length=50, description="基礎帳號名稱 (全公司唯一)")
|
||||
legal_name: str = Field(..., min_length=2, max_length=100, description="法定姓名")
|
||||
english_name: Optional[str] = Field(None, max_length=100, description="英文名稱")
|
||||
phone: Optional[str] = Field(None, max_length=20, description="電話")
|
||||
mobile: Optional[str] = Field(None, max_length=20, description="手機")
|
||||
|
||||
|
||||
class EmployeeCreate(EmployeeBase):
|
||||
"""創建員工 Schema (多層部門架構: department_id 指向任何層部門)"""
|
||||
|
||||
hire_date: date = Field(..., description="到職日期")
|
||||
|
||||
# 組織資訊 (新多層部門架構)
|
||||
department_id: Optional[int] = Field(None, description="部門 ID (任何層級,選填)")
|
||||
job_title: str = Field(..., min_length=2, max_length=100, description="職稱")
|
||||
email_quota_mb: int = Field(5120, gt=0, description="郵件配額 (MB),預設 5120")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"username_base": "porsche.chen",
|
||||
"legal_name": "陳保時",
|
||||
"english_name": "Porsche Chen",
|
||||
"phone": "02-1234-5678",
|
||||
"mobile": "0912-345-678",
|
||||
"hire_date": "2020-01-01",
|
||||
"department_id": 2,
|
||||
"job_title": "軟體工程師",
|
||||
"email_quota_mb": 5120
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmployeeUpdate(BaseSchema):
|
||||
"""更新員工 Schema"""
|
||||
|
||||
legal_name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
english_name: Optional[str] = Field(None, max_length=100)
|
||||
phone: Optional[str] = Field(None, max_length=20)
|
||||
mobile: Optional[str] = Field(None, max_length=20)
|
||||
status: Optional[EmployeeStatus] = None
|
||||
|
||||
|
||||
class EmployeeInDB(EmployeeBase, TimestampSchema):
|
||||
"""資料庫中的員工 Schema"""
|
||||
|
||||
id: int
|
||||
employee_id: str = Field(..., description="員工編號 (EMP001)")
|
||||
hire_date: date
|
||||
status: EmployeeStatus
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EmployeeResponse(EmployeeInDB):
|
||||
"""員工響應 Schema (多部門成員架構)"""
|
||||
|
||||
has_network_drive: Optional[bool] = Field(None, description="是否有 NAS 帳號")
|
||||
department_count: Optional[int] = Field(None, description="所屬部門數量")
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"employee_id": "EMP001",
|
||||
"username_base": "porsche.chen",
|
||||
"legal_name": "陳保時",
|
||||
"english_name": "Porsche Chen",
|
||||
"phone": "02-1234-5678",
|
||||
"mobile": "0912-345-678",
|
||||
"hire_date": "2020-01-01",
|
||||
"status": "active",
|
||||
"has_network_drive": True,
|
||||
"department_count": 2,
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
"updated_at": "2020-01-01T00:00:00"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmployeeListItem(BaseSchema):
|
||||
"""員工列表項 Schema (簡化版,多部門成員架構)"""
|
||||
|
||||
id: int
|
||||
employee_id: str
|
||||
username_base: str
|
||||
legal_name: str
|
||||
english_name: Optional[str] = None
|
||||
status: EmployeeStatus
|
||||
hire_date: date
|
||||
|
||||
# 主要部門資訊 (從 department_memberships 取得)
|
||||
primary_department: Optional[str] = Field(None, description="主要部門名稱")
|
||||
primary_job_title: Optional[str] = Field(None, description="職稱")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EmployeeDetail(EmployeeInDB):
|
||||
"""員工詳情 Schema (包含完整關聯資料)"""
|
||||
|
||||
# 將在後續添加 identities 和 network_drive
|
||||
pass
|
||||
118
backend/app/schemas/employee_identity.py
Normal file
118
backend/app/schemas/employee_identity.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
員工身份 Schemas
|
||||
"""
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from pydantic import Field, ConfigDict
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class EmployeeIdentityBase(BaseSchema):
|
||||
"""員工身份基礎 Schema"""
|
||||
|
||||
job_title: str = Field(..., min_length=2, max_length=100, description="職稱")
|
||||
job_level: str = Field(..., description="職級 (Junior/Mid/Senior/Manager)")
|
||||
email_quota_mb: int = Field(..., gt=0, description="郵件配額 (MB)")
|
||||
|
||||
|
||||
class EmployeeIdentityCreate(EmployeeIdentityBase):
|
||||
"""創建員工身份 Schema"""
|
||||
|
||||
employee_id: int = Field(..., description="員工 ID")
|
||||
business_unit_id: int = Field(..., description="事業部 ID")
|
||||
department_id: Optional[int] = Field(None, description="部門 ID")
|
||||
is_primary: bool = Field(False, description="是否為主要身份")
|
||||
started_at: date = Field(..., description="開始日期")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"employee_id": 1,
|
||||
"business_unit_id": 2,
|
||||
"department_id": 4,
|
||||
"job_title": "技術總監",
|
||||
"job_level": "Senior",
|
||||
"email_quota_mb": 5000,
|
||||
"is_primary": True,
|
||||
"started_at": "2020-01-01"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmployeeIdentityUpdate(BaseSchema):
|
||||
"""更新員工身份 Schema"""
|
||||
|
||||
department_id: Optional[int] = None
|
||||
job_title: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
job_level: Optional[str] = None
|
||||
email_quota_mb: Optional[int] = Field(None, gt=0)
|
||||
is_primary: Optional[bool] = None
|
||||
ended_at: Optional[date] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class EmployeeIdentityInDB(EmployeeIdentityBase, TimestampSchema):
|
||||
"""資料庫中的員工身份 Schema"""
|
||||
|
||||
id: int
|
||||
employee_id: int
|
||||
username: str = Field(..., description="SSO 帳號")
|
||||
keycloak_id: str = Field(..., description="Keycloak UUID")
|
||||
business_unit_id: int
|
||||
department_id: Optional[int] = None
|
||||
is_primary: bool
|
||||
started_at: date
|
||||
ended_at: Optional[date] = None
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EmployeeIdentityResponse(EmployeeIdentityInDB):
|
||||
"""員工身份響應 Schema"""
|
||||
|
||||
employee_name: Optional[str] = Field(None, description="員工姓名")
|
||||
business_unit_name: Optional[str] = Field(None, description="事業部名稱")
|
||||
department_name: Optional[str] = Field(None, description="部門名稱")
|
||||
email_domain: Optional[str] = Field(None, description="郵件網域")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"employee_id": 1,
|
||||
"username": "porsche.chen@lab.taipei",
|
||||
"keycloak_id": "abc123-uuid",
|
||||
"business_unit_id": 2,
|
||||
"department_id": 4,
|
||||
"job_title": "技術總監",
|
||||
"job_level": "Senior",
|
||||
"email_quota_mb": 5000,
|
||||
"is_primary": True,
|
||||
"started_at": "2020-01-01",
|
||||
"ended_at": None,
|
||||
"is_active": True,
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
"updated_at": "2020-01-01T00:00:00",
|
||||
"employee_name": "陳保時",
|
||||
"business_unit_name": "智能發展部",
|
||||
"department_name": "資訊部",
|
||||
"email_domain": "lab.taipei"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmployeeIdentityListItem(BaseSchema):
|
||||
"""員工身份列表項 Schema"""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
job_title: str
|
||||
job_level: str
|
||||
is_primary: bool
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
105
backend/app/schemas/network_drive.py
Normal file
105
backend/app/schemas/network_drive.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
網路硬碟 Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import Field, ConfigDict
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
class NetworkDriveBase(BaseSchema):
|
||||
"""網路硬碟基礎 Schema"""
|
||||
|
||||
quota_gb: int = Field(..., gt=0, description="配額 (GB)")
|
||||
webdav_url: Optional[str] = Field(None, max_length=255, description="WebDAV 路徑")
|
||||
smb_url: Optional[str] = Field(None, max_length=255, description="SMB 路徑")
|
||||
|
||||
|
||||
class NetworkDriveCreate(NetworkDriveBase):
|
||||
"""創建網路硬碟 Schema"""
|
||||
|
||||
employee_id: int = Field(..., description="員工 ID")
|
||||
drive_name: str = Field(..., min_length=3, max_length=100, description="NAS 帳號名稱")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"employee_id": 1,
|
||||
"drive_name": "porsche.chen",
|
||||
"quota_gb": 200,
|
||||
"webdav_url": "https://nas.lab.taipei/webdav/porsche.chen",
|
||||
"smb_url": "\\\\10.1.0.30\\porsche.chen"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NetworkDriveUpdate(BaseSchema):
|
||||
"""更新網路硬碟 Schema"""
|
||||
|
||||
quota_gb: Optional[int] = Field(None, gt=0)
|
||||
webdav_url: Optional[str] = Field(None, max_length=255)
|
||||
smb_url: Optional[str] = Field(None, max_length=255)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class NetworkDriveInDB(NetworkDriveBase, TimestampSchema):
|
||||
"""資料庫中的網路硬碟 Schema"""
|
||||
|
||||
id: int
|
||||
employee_id: int
|
||||
drive_name: str
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NetworkDriveResponse(NetworkDriveInDB):
|
||||
"""網路硬碟響應 Schema"""
|
||||
|
||||
employee_name: Optional[str] = Field(None, description="員工姓名")
|
||||
employee_username: Optional[str] = Field(None, description="員工基礎帳號")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"employee_id": 1,
|
||||
"drive_name": "porsche.chen",
|
||||
"quota_gb": 200,
|
||||
"webdav_url": "https://nas.lab.taipei/webdav/porsche.chen",
|
||||
"smb_url": "\\\\10.1.0.30\\porsche.chen",
|
||||
"is_active": True,
|
||||
"created_at": "2020-01-01T00:00:00",
|
||||
"updated_at": "2020-01-01T00:00:00",
|
||||
"employee_name": "陳保時",
|
||||
"employee_username": "porsche.chen"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NetworkDriveListItem(BaseSchema):
|
||||
"""網路硬碟列表項 Schema"""
|
||||
|
||||
id: int
|
||||
drive_name: str
|
||||
quota_gb: int
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NetworkDriveQuotaUpdate(BaseSchema):
|
||||
"""更新配額 Schema"""
|
||||
|
||||
quota_gb: int = Field(..., gt=0, le=1000, description="新配額 (GB)")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"quota_gb": 500
|
||||
}
|
||||
}
|
||||
)
|
||||
167
backend/app/schemas/permission.py
Normal file
167
backend/app/schemas/permission.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
權限 Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||
|
||||
from app.schemas.base import BaseSchema, TimestampSchema
|
||||
|
||||
|
||||
# 系統名稱常數
|
||||
VALID_SYSTEMS = ["gitea", "portainer", "traefik", "keycloak"]
|
||||
|
||||
# 存取層級常數
|
||||
VALID_ACCESS_LEVELS = ["admin", "user", "readonly"]
|
||||
|
||||
|
||||
class PermissionBase(BaseSchema):
|
||||
"""權限基礎 Schema"""
|
||||
|
||||
system_name: str = Field(..., description="系統名稱: gitea, portainer, traefik, keycloak")
|
||||
access_level: str = Field("user", description="存取層級: admin, user, readonly")
|
||||
|
||||
@field_validator('system_name')
|
||||
@classmethod
|
||||
def validate_system_name(cls, v):
|
||||
"""驗證系統名稱"""
|
||||
if v.lower() not in VALID_SYSTEMS:
|
||||
raise ValueError(f"system_name 必須是以下之一: {', '.join(VALID_SYSTEMS)}")
|
||||
return v.lower()
|
||||
|
||||
@field_validator('access_level')
|
||||
@classmethod
|
||||
def validate_access_level(cls, v):
|
||||
"""驗證存取層級"""
|
||||
if v.lower() not in VALID_ACCESS_LEVELS:
|
||||
raise ValueError(f"access_level 必須是以下之一: {', '.join(VALID_ACCESS_LEVELS)}")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class PermissionCreate(PermissionBase):
|
||||
"""創建權限 Schema"""
|
||||
|
||||
employee_id: int = Field(..., description="員工 ID")
|
||||
granted_by: Optional[int] = Field(None, description="授予人 ID")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"employee_id": 1,
|
||||
"system_name": "gitea",
|
||||
"access_level": "user",
|
||||
"granted_by": 2
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PermissionUpdate(BaseSchema):
|
||||
"""更新權限 Schema"""
|
||||
|
||||
access_level: str = Field(..., description="存取層級: admin, user, readonly")
|
||||
granted_by: Optional[int] = Field(None, description="授予人 ID")
|
||||
|
||||
@field_validator('access_level')
|
||||
@classmethod
|
||||
def validate_access_level(cls, v):
|
||||
"""驗證存取層級"""
|
||||
if v.lower() not in VALID_ACCESS_LEVELS:
|
||||
raise ValueError(f"access_level 必須是以下之一: {', '.join(VALID_ACCESS_LEVELS)}")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class PermissionInDB(PermissionBase):
|
||||
"""資料庫中的權限 Schema"""
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
employee_id: int
|
||||
granted_at: datetime
|
||||
granted_by: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PermissionResponse(PermissionInDB):
|
||||
"""權限響應 Schema (包含關聯資料)"""
|
||||
|
||||
employee_name: Optional[str] = Field(None, description="員工姓名")
|
||||
employee_number: Optional[str] = Field(None, description="員工編號")
|
||||
granted_by_name: Optional[str] = Field(None, description="授予人姓名")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"tenant_id": 1,
|
||||
"employee_id": 1,
|
||||
"system_name": "gitea",
|
||||
"access_level": "admin",
|
||||
"granted_at": "2020-01-01T00:00:00",
|
||||
"granted_by": 2,
|
||||
"employee_name": "陳保時",
|
||||
"employee_number": "EMP001",
|
||||
"granted_by_name": "管理員"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PermissionListItem(BaseSchema):
|
||||
"""權限列表項 Schema (簡化版)"""
|
||||
|
||||
id: int
|
||||
employee_id: int
|
||||
system_name: str
|
||||
access_level: str
|
||||
granted_at: datetime
|
||||
employee_name: Optional[str] = None
|
||||
employee_number: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PermissionBatchCreate(BaseSchema):
|
||||
"""批量創建權限 Schema"""
|
||||
|
||||
employee_id: int = Field(..., description="員工 ID")
|
||||
permissions: list[PermissionBase] = Field(..., description="權限列表")
|
||||
granted_by: Optional[int] = Field(None, description="授予人 ID")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"employee_id": 1,
|
||||
"permissions": [
|
||||
{"system_name": "gitea", "access_level": "user"},
|
||||
{"system_name": "portainer", "access_level": "readonly"}
|
||||
],
|
||||
"granted_by": 2
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PermissionFilter(BaseSchema):
|
||||
"""權限篩選 Schema"""
|
||||
|
||||
employee_id: Optional[int] = Field(None, description="員工 ID")
|
||||
system_name: Optional[str] = Field(None, description="系統名稱")
|
||||
access_level: Optional[str] = Field(None, description="存取層級")
|
||||
|
||||
@field_validator('system_name')
|
||||
@classmethod
|
||||
def validate_system_name(cls, v):
|
||||
"""驗證系統名稱"""
|
||||
if v and v.lower() not in VALID_SYSTEMS:
|
||||
raise ValueError(f"system_name 必須是以下之一: {', '.join(VALID_SYSTEMS)}")
|
||||
return v.lower() if v else None
|
||||
|
||||
@field_validator('access_level')
|
||||
@classmethod
|
||||
def validate_access_level(cls, v):
|
||||
"""驗證存取層級"""
|
||||
if v and v.lower() not in VALID_ACCESS_LEVELS:
|
||||
raise ValueError(f"access_level 必須是以下之一: {', '.join(VALID_ACCESS_LEVELS)}")
|
||||
return v.lower() if v else None
|
||||
37
backend/app/schemas/response.py
Normal file
37
backend/app/schemas/response.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
通用響應 Schemas
|
||||
"""
|
||||
from typing import Optional, Any, Generic, TypeVar
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class ResponseModel(BaseModel, Generic[T]):
|
||||
"""通用響應模型"""
|
||||
|
||||
success: bool = Field(True, description="操作是否成功")
|
||||
message: Optional[str] = Field(None, description="響應訊息")
|
||||
data: Optional[T] = Field(None, description="響應數據")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""錯誤響應"""
|
||||
|
||||
success: bool = Field(False, description="操作是否成功")
|
||||
message: str = Field(..., description="錯誤訊息")
|
||||
error_code: Optional[str] = Field(None, description="錯誤代碼")
|
||||
details: Optional[Any] = Field(None, description="錯誤詳情")
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""簡單訊息響應"""
|
||||
|
||||
message: str = Field(..., description="響應訊息")
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""成功響應"""
|
||||
|
||||
success: bool = Field(True, description="操作是否成功")
|
||||
message: str = Field(..., description="成功訊息")
|
||||
114
backend/app/schemas/system_function.py
Normal file
114
backend/app/schemas/system_function.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
SystemFunction Schemas
|
||||
系統功能明細 API 資料結構
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SystemFunctionBase(BaseModel):
|
||||
"""系統功能基礎 Schema"""
|
||||
code: str = Field(..., max_length=200, description="系統功能代碼/功能英文名稱")
|
||||
upper_function_id: int = Field(0, description="上層功能代碼 (0為初始層)")
|
||||
name: str = Field(..., max_length=200, description="系統功能中文名稱")
|
||||
function_type: int = Field(..., description="系統功能類型 (1:node, 2:function)")
|
||||
order: int = Field(..., description="系統功能次序")
|
||||
function_icon: str = Field("", max_length=200, description="功能圖示")
|
||||
module_code: Optional[str] = Field(None, max_length=200, description="功能模組名稱")
|
||||
module_functions: List[str] = Field(default_factory=list, description="模組項目")
|
||||
description: str = Field("", description="說明 (富文本格式)")
|
||||
is_mana: bool = Field(True, description="系統管理")
|
||||
is_active: bool = Field(True, description="啟用")
|
||||
|
||||
@field_validator('function_type')
|
||||
@classmethod
|
||||
def validate_function_type(cls, v):
|
||||
"""驗證功能類型"""
|
||||
if v not in [1, 2]:
|
||||
raise ValueError('function_type 必須為 1 (node) 或 2 (function)')
|
||||
return v
|
||||
|
||||
@field_validator('module_functions')
|
||||
@classmethod
|
||||
def validate_module_functions(cls, v):
|
||||
"""驗證模組項目"""
|
||||
allowed_functions = ['View', 'Create', 'Read', 'Update', 'Delete', 'Print', 'File']
|
||||
for func in v:
|
||||
if func not in allowed_functions:
|
||||
raise ValueError(f'module_functions 只能包含: {", ".join(allowed_functions)}')
|
||||
return v
|
||||
|
||||
@field_validator('upper_function_id')
|
||||
@classmethod
|
||||
def validate_upper_function_id(cls, v, values):
|
||||
"""驗證上層功能代碼"""
|
||||
# upper_function_id 必須是 function_type=1 且 is_active=1 的功能, 或 0 (初始層)
|
||||
if v < 0:
|
||||
raise ValueError('upper_function_id 不能小於 0')
|
||||
return v
|
||||
|
||||
|
||||
class SystemFunctionCreate(SystemFunctionBase):
|
||||
"""系統功能建立 Schema"""
|
||||
edit_by: int = Field(..., description="資料建立者")
|
||||
|
||||
@field_validator('module_code')
|
||||
@classmethod
|
||||
def validate_module_code_create(cls, v, info):
|
||||
"""驗證 module_code (function_type=2 必填)"""
|
||||
function_type = info.data.get('function_type')
|
||||
if function_type == 2 and not v:
|
||||
raise ValueError('function_type=2 時, module_code 為必填')
|
||||
if function_type == 1 and v:
|
||||
raise ValueError('function_type=1 時, module_code 不能輸入')
|
||||
return v
|
||||
|
||||
@field_validator('module_functions')
|
||||
@classmethod
|
||||
def validate_module_functions_create(cls, v, info):
|
||||
"""驗證 module_functions (function_type=2 必填)"""
|
||||
function_type = info.data.get('function_type')
|
||||
if function_type == 2 and not v:
|
||||
raise ValueError('function_type=2 時, module_functions 為必填')
|
||||
return v
|
||||
|
||||
|
||||
class SystemFunctionUpdate(BaseModel):
|
||||
"""系統功能更新 Schema (部分更新)"""
|
||||
code: Optional[str] = Field(None, max_length=200)
|
||||
upper_function_id: Optional[int] = None
|
||||
name: Optional[str] = Field(None, max_length=200)
|
||||
function_type: Optional[int] = None
|
||||
order: Optional[int] = None
|
||||
function_icon: Optional[str] = Field(None, max_length=200)
|
||||
module_code: Optional[str] = Field(None, max_length=200)
|
||||
module_functions: Optional[List[str]] = None
|
||||
description: Optional[str] = None
|
||||
is_mana: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
edit_by: int = Field(..., description="資料編輯者")
|
||||
|
||||
|
||||
class SystemFunctionInDB(SystemFunctionBase):
|
||||
"""系統功能資料庫 Schema"""
|
||||
id: int
|
||||
edit_by: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SystemFunctionResponse(SystemFunctionInDB):
|
||||
"""系統功能回應 Schema"""
|
||||
pass
|
||||
|
||||
|
||||
class SystemFunctionListResponse(BaseModel):
|
||||
"""系統功能列表回應"""
|
||||
total: int
|
||||
items: List[SystemFunctionResponse]
|
||||
page: int
|
||||
page_size: int
|
||||
134
backend/app/schemas/tenant.py
Normal file
134
backend/app/schemas/tenant.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
租戶相關 Pydantic Schemas
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
"""租戶基本資料"""
|
||||
code: str = Field(..., min_length=2, max_length=50, description="租戶代碼")
|
||||
name: str = Field(..., min_length=1, max_length=200, description="公司名稱")
|
||||
name_eng: Optional[str] = Field(None, max_length=200, description="公司英文名稱")
|
||||
tax_id: Optional[str] = Field(None, max_length=20, description="統一編號")
|
||||
prefix: str = Field(..., min_length=1, max_length=10, description="員工編號前綴")
|
||||
tel: Optional[str] = Field(None, max_length=50, description="公司電話")
|
||||
add: Optional[str] = Field(None, max_length=500, description="公司地址")
|
||||
url: Optional[str] = Field(None, max_length=200, description="公司網站")
|
||||
plan_id: str = Field(default="starter", description="方案 ID")
|
||||
max_users: int = Field(default=5, ge=1, description="最大用戶數")
|
||||
storage_quota_gb: int = Field(default=100, ge=1, description="總儲存配額 (GB)")
|
||||
|
||||
|
||||
class TenantCreateRequest(TenantBase):
|
||||
"""建立租戶請求 (Superuser only)"""
|
||||
admin_username: str = Field(..., description="Tenant Admin 帳號")
|
||||
admin_email: str = Field(..., description="Tenant Admin 郵件")
|
||||
admin_name: str = Field(..., description="Tenant Admin 姓名")
|
||||
admin_temp_password: str = Field(..., min_length=8, description="Tenant Admin 臨時密碼")
|
||||
|
||||
@validator('admin_email')
|
||||
def validate_email(cls, v):
|
||||
"""驗證郵件格式"""
|
||||
if '@' not in v:
|
||||
raise ValueError('Invalid email format')
|
||||
return v
|
||||
|
||||
@validator('tax_id')
|
||||
def validate_tax_id(cls, v):
|
||||
"""驗證統一編號 (台灣 8 位數字)"""
|
||||
if v and (not v.isdigit() or len(v) != 8):
|
||||
raise ValueError('Tax ID must be 8 digits')
|
||||
return v
|
||||
|
||||
|
||||
class TenantCreateResponse(BaseModel):
|
||||
"""建立租戶回應"""
|
||||
message: str
|
||||
tenant: dict
|
||||
admin_user: dict
|
||||
keycloak_realm: str
|
||||
temporary_password: str # 返回臨時密碼供管理員記錄
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TenantUpdateRequest(BaseModel):
|
||||
"""更新租戶請求"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
name_eng: Optional[str] = Field(None, max_length=200)
|
||||
tax_id: Optional[str] = Field(None, max_length=20)
|
||||
tel: Optional[str] = Field(None, max_length=50)
|
||||
add: Optional[str] = Field(None, max_length=500)
|
||||
url: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
@validator('tax_id')
|
||||
def validate_tax_id(cls, v):
|
||||
"""驗證統一編號"""
|
||||
if v and (not v.isdigit() or len(v) != 8):
|
||||
raise ValueError('Tax ID must be 8 digits')
|
||||
return v
|
||||
|
||||
|
||||
class TenantUpdateResponse(BaseModel):
|
||||
"""更新租戶回應"""
|
||||
message: str
|
||||
tenant: dict
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
"""租戶詳細資訊"""
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
name_eng: Optional[str]
|
||||
tax_id: Optional[str]
|
||||
prefix: str
|
||||
tel: Optional[str]
|
||||
add: Optional[str]
|
||||
url: Optional[str]
|
||||
keycloak_realm: Optional[str]
|
||||
plan_id: str
|
||||
max_users: int
|
||||
storage_quota_gb: int
|
||||
status: str
|
||||
is_sysmana: bool
|
||||
is_active: bool
|
||||
is_initialized: bool
|
||||
initialized_at: Optional[datetime]
|
||||
initialized_by: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InitializationRequest(BaseModel):
|
||||
"""租戶初始化請求 (Tenant Admin only)"""
|
||||
|
||||
# Step 1: 公司基本資料 (可修改)
|
||||
company_info: dict = Field(..., description="公司基本資料")
|
||||
|
||||
# Step 2: 部門結構
|
||||
departments: List[dict] = Field(..., description="部門列表")
|
||||
|
||||
# Step 3: 系統角色
|
||||
roles: List[dict] = Field(..., description="角色列表")
|
||||
|
||||
# Step 4: 預設配額與服務
|
||||
default_settings: dict = Field(..., description="預設配額與服務")
|
||||
|
||||
|
||||
class InitializationResponse(BaseModel):
|
||||
"""初始化完成回應"""
|
||||
message: str
|
||||
summary: dict
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
10
backend/app/services/__init__.py
Normal file
10
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Services 模組
|
||||
匯出所有業務邏輯服務
|
||||
"""
|
||||
from app.services.audit_service import audit_service, AuditService
|
||||
|
||||
__all__ = [
|
||||
"audit_service",
|
||||
"AuditService",
|
||||
]
|
||||
257
backend/app/services/audit_service.py
Normal file
257
backend/app/services/audit_service.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
審計日誌服務
|
||||
自動記錄所有 CRUD 操作,符合 ISO 要求
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Request
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""審計日誌服務類別"""
|
||||
|
||||
@staticmethod
|
||||
def log(
|
||||
db: Session,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
performed_by: str,
|
||||
resource_id: Optional[int] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""
|
||||
創建審計日誌
|
||||
|
||||
Args:
|
||||
db: 資料庫 Session
|
||||
action: 操作類型 (create/update/delete/login/logout)
|
||||
resource_type: 資源類型 (employee/identity/department/etc)
|
||||
performed_by: 操作者 SSO 帳號
|
||||
resource_id: 資源 ID
|
||||
details: 詳細變更內容 (dict)
|
||||
ip_address: IP 位址
|
||||
|
||||
Returns:
|
||||
AuditLog: 創建的審計日誌物件
|
||||
"""
|
||||
# TODO: 從 JWT token 或 session 獲取 tenant_id,目前暫時使用預設值 1
|
||||
tenant_id = 1
|
||||
|
||||
audit_log = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
db.add(audit_log)
|
||||
db.commit()
|
||||
db.refresh(audit_log)
|
||||
|
||||
return audit_log
|
||||
|
||||
@staticmethod
|
||||
def log_create(
|
||||
db: Session,
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
performed_by: str,
|
||||
details: Dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""記錄創建操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="create",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_update(
|
||||
db: Session,
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
performed_by: str,
|
||||
old_values: Dict[str, Any],
|
||||
new_values: Dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""
|
||||
記錄更新操作
|
||||
|
||||
Args:
|
||||
old_values: 舊值
|
||||
new_values: 新值
|
||||
"""
|
||||
details = {
|
||||
"old": old_values,
|
||||
"new": new_values,
|
||||
"changed_fields": list(set(old_values.keys()) & set(new_values.keys()))
|
||||
}
|
||||
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="update",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_delete(
|
||||
db: Session,
|
||||
resource_type: str,
|
||||
resource_id: int,
|
||||
performed_by: str,
|
||||
details: Dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""記錄刪除操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="delete",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_login(
|
||||
db: Session,
|
||||
username: str,
|
||||
ip_address: Optional[str] = None,
|
||||
success: bool = True,
|
||||
) -> AuditLog:
|
||||
"""記錄登入操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="login",
|
||||
resource_type="authentication",
|
||||
performed_by=username,
|
||||
details={"success": success},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_logout(
|
||||
db: Session,
|
||||
username: str,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AuditLog:
|
||||
"""記錄登出操作"""
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action="logout",
|
||||
resource_type="authentication",
|
||||
performed_by=username,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_client_ip(request: Request) -> Optional[str]:
|
||||
"""
|
||||
從 Request 獲取客戶端 IP
|
||||
|
||||
優先順序:
|
||||
1. X-Forwarded-For (代理服務器)
|
||||
2. X-Real-IP (Nginx)
|
||||
3. request.client.host (直接連接)
|
||||
"""
|
||||
# 檢查 X-Forwarded-For
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# 取第一個 IP (客戶端真實 IP)
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
# 檢查 X-Real-IP
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# 直接連接
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def model_to_dict(obj, exclude_fields: Optional[set] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
將 SQLAlchemy Model 轉換為 dict (用於審計日誌)
|
||||
|
||||
Args:
|
||||
obj: SQLAlchemy Model 物件
|
||||
exclude_fields: 排除的欄位集合
|
||||
|
||||
Returns:
|
||||
dict: 模型的 dict 表示
|
||||
"""
|
||||
if exclude_fields is None:
|
||||
exclude_fields = {"created_at", "updated_at", "_sa_instance_state"}
|
||||
|
||||
result = {}
|
||||
for column in obj.__table__.columns:
|
||||
if column.name not in exclude_fields:
|
||||
value = getattr(obj, column.name)
|
||||
# 處理 datetime
|
||||
if isinstance(value, datetime):
|
||||
value = value.isoformat()
|
||||
result[column.name] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@staticmethod
|
||||
def log_action(
|
||||
db: Session,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: Optional[int] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request: Optional[Request] = None,
|
||||
performed_by: str = "system",
|
||||
) -> AuditLog:
|
||||
"""
|
||||
通用操作記錄 (permissions.py 使用的介面)
|
||||
|
||||
Args:
|
||||
db: 資料庫 Session
|
||||
action: 操作類型
|
||||
resource_type: 資源類型
|
||||
resource_id: 資源 ID
|
||||
details: 詳細內容
|
||||
request: FastAPI Request 物件 (用於取得 IP)
|
||||
performed_by: 操作者
|
||||
"""
|
||||
ip_address = None
|
||||
if request is not None:
|
||||
ip_address = AuditService.get_client_ip(request)
|
||||
|
||||
return AuditService.log(
|
||||
db=db,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
# 全域審計服務實例
|
||||
audit_service = AuditService()
|
||||
281
backend/app/services/drive_service.py
Normal file
281
backend/app/services/drive_service.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Drive Service HTTP Client
|
||||
呼叫 drive-api.ease.taipei 的 RESTful API 管理 Nextcloud 雲端硬碟帳號
|
||||
|
||||
架構說明:
|
||||
- Drive Service 是獨立的微服務 (尚未部署)
|
||||
- 本 Client 以非致命方式處理失敗 (失敗只記錄 warning,不影響其他流程)
|
||||
- Drive Service 上線後自動生效,無需修改 HR Portal 核心邏輯
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, Timeout, RequestException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Drive Service API 配額配置 (GB),與 NAS 配額相同
|
||||
DRIVE_QUOTA_BY_JOB_LEVEL = {
|
||||
"Junior": 50,
|
||||
"Mid": 100,
|
||||
"Senior": 200,
|
||||
"Manager": 500,
|
||||
}
|
||||
|
||||
|
||||
class DriveServiceClient:
|
||||
"""
|
||||
Drive Service HTTP Client
|
||||
|
||||
透過 REST API 管理 Nextcloud 雲端硬碟帳號:
|
||||
- 創建帳號 (POST /api/v1/drive/users)
|
||||
- 查詢配額 (GET /api/v1/drive/users/{id}/quota)
|
||||
- 更新配額 (PUT /api/v1/drive/users/{id}/quota)
|
||||
- 停用帳號 (DELETE /api/v1/drive/users/{id})
|
||||
|
||||
失敗處理原則:
|
||||
- Drive Service 未上線時,連線失敗以 warning 記錄
|
||||
- 不拋出例外,回傳包含 error 的結果字典
|
||||
- 不影響 Keycloak、郵件等其他 onboarding 流程
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: int = 10):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
|
||||
def _is_available(self) -> bool:
|
||||
"""快速檢查 Drive Service 是否可用"""
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}/health",
|
||||
timeout=3,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except (ConnectionError, Timeout):
|
||||
return False
|
||||
except RequestException:
|
||||
return False
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
tenant_id: int,
|
||||
keycloak_user_id: str,
|
||||
username: str,
|
||||
email: str,
|
||||
display_name: str,
|
||||
quota_gb: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
創建 Nextcloud 帳號
|
||||
|
||||
POST /api/v1/drive/users
|
||||
|
||||
Args:
|
||||
tenant_id: 租戶 ID
|
||||
keycloak_user_id: Keycloak UUID
|
||||
username: Nextcloud 使用者名稱 (username_base)
|
||||
email: 電子郵件
|
||||
display_name: 顯示名稱
|
||||
quota_gb: 配額 (GB)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"created": True/False,
|
||||
"user_id": int or None,
|
||||
"username": str,
|
||||
"quota_gb": int,
|
||||
"drive_url": str,
|
||||
"message": str,
|
||||
"error": str or None,
|
||||
}
|
||||
"""
|
||||
try:
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}/api/v1/drive/users",
|
||||
json={
|
||||
"tenant_id": tenant_id,
|
||||
"keycloak_user_id": keycloak_user_id,
|
||||
"nextcloud_username": username,
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
"quota_gb": quota_gb,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if resp.status_code == 201:
|
||||
data = resp.json()
|
||||
logger.info(f"Drive Service: 帳號建立成功 {username} ({quota_gb}GB)")
|
||||
return {
|
||||
"created": True,
|
||||
"user_id": data.get("id"),
|
||||
"username": username,
|
||||
"quota_gb": quota_gb,
|
||||
"drive_url": f"https://drive.ease.taipei",
|
||||
"message": f"雲端硬碟帳號建立成功 ({quota_gb}GB)",
|
||||
"error": None,
|
||||
}
|
||||
elif resp.status_code == 409:
|
||||
logger.warning(f"Drive Service: 帳號已存在 {username}")
|
||||
return {
|
||||
"created": False,
|
||||
"user_id": None,
|
||||
"username": username,
|
||||
"quota_gb": quota_gb,
|
||||
"drive_url": f"https://drive.ease.taipei",
|
||||
"message": "雲端硬碟帳號已存在",
|
||||
"error": "帳號已存在",
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Drive Service: 建立帳號失敗 {username} - HTTP {resp.status_code}")
|
||||
return {
|
||||
"created": False,
|
||||
"user_id": None,
|
||||
"username": username,
|
||||
"quota_gb": quota_gb,
|
||||
"drive_url": None,
|
||||
"message": f"雲端硬碟帳號建立失敗 (HTTP {resp.status_code})",
|
||||
"error": resp.text[:200],
|
||||
}
|
||||
|
||||
except (ConnectionError, Timeout):
|
||||
logger.warning(f"Drive Service 未上線或無法連線,跳過帳號建立: {username}")
|
||||
return {
|
||||
"created": False,
|
||||
"user_id": None,
|
||||
"username": username,
|
||||
"quota_gb": quota_gb,
|
||||
"drive_url": None,
|
||||
"message": "Drive Service 尚未上線,跳過雲端硬碟帳號建立",
|
||||
"error": "Drive Service 無法連線",
|
||||
}
|
||||
except RequestException as e:
|
||||
logger.warning(f"Drive Service 請求失敗: {e}")
|
||||
return {
|
||||
"created": False,
|
||||
"user_id": None,
|
||||
"username": username,
|
||||
"quota_gb": quota_gb,
|
||||
"drive_url": None,
|
||||
"message": "Drive Service 請求失敗",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def get_quota(self, drive_user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
查詢配額使用量
|
||||
|
||||
GET /api/v1/drive/users/{id}/quota
|
||||
|
||||
Returns:
|
||||
{
|
||||
"quota_gb": float,
|
||||
"used_gb": float,
|
||||
"usage_percentage": float,
|
||||
"warning_threshold": bool, # >= 80%
|
||||
"alert_threshold": bool, # >= 95%
|
||||
}
|
||||
or None if Drive Service unavailable
|
||||
"""
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}/api/v1/drive/users/{drive_user_id}/quota",
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
else:
|
||||
logger.warning(f"Drive Service: 查詢配額失敗 user_id={drive_user_id} - HTTP {resp.status_code}")
|
||||
return None
|
||||
|
||||
except (ConnectionError, Timeout, RequestException):
|
||||
logger.warning(f"Drive Service 無法連線,無法查詢配額 user_id={drive_user_id}")
|
||||
return None
|
||||
|
||||
def update_quota(self, drive_user_id: int, quota_gb: int) -> Dict[str, Any]:
|
||||
"""
|
||||
更新配額
|
||||
|
||||
PUT /api/v1/drive/users/{id}/quota
|
||||
|
||||
Returns:
|
||||
{"updated": True/False, "quota_gb": int, "error": str or None}
|
||||
"""
|
||||
try:
|
||||
resp = self.session.put(
|
||||
f"{self.base_url}/api/v1/drive/users/{drive_user_id}/quota",
|
||||
json={"quota_gb": quota_gb},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
logger.info(f"Drive Service: 配額更新成功 user_id={drive_user_id} -> {quota_gb}GB")
|
||||
return {"updated": True, "quota_gb": quota_gb, "error": None}
|
||||
else:
|
||||
logger.warning(f"Drive Service: 配額更新失敗 user_id={drive_user_id} - HTTP {resp.status_code}")
|
||||
return {
|
||||
"updated": False,
|
||||
"quota_gb": quota_gb,
|
||||
"error": f"HTTP {resp.status_code}",
|
||||
}
|
||||
|
||||
except (ConnectionError, Timeout, RequestException) as e:
|
||||
logger.warning(f"Drive Service 無法連線,無法更新配額: {e}")
|
||||
return {"updated": False, "quota_gb": quota_gb, "error": "Drive Service 無法連線"}
|
||||
|
||||
def disable_user(self, drive_user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
停用帳號 (軟刪除)
|
||||
|
||||
DELETE /api/v1/drive/users/{id}
|
||||
|
||||
Returns:
|
||||
{"disabled": True/False, "error": str or None}
|
||||
"""
|
||||
try:
|
||||
resp = self.session.delete(
|
||||
f"{self.base_url}/api/v1/drive/users/{drive_user_id}",
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if resp.status_code in (200, 204):
|
||||
logger.info(f"Drive Service: 帳號停用成功 user_id={drive_user_id}")
|
||||
return {"disabled": True, "error": None}
|
||||
else:
|
||||
logger.warning(f"Drive Service: 帳號停用失敗 user_id={drive_user_id} - HTTP {resp.status_code}")
|
||||
return {
|
||||
"disabled": False,
|
||||
"error": f"HTTP {resp.status_code}",
|
||||
}
|
||||
|
||||
except (ConnectionError, Timeout, RequestException) as e:
|
||||
logger.warning(f"Drive Service 無法連線,無法停用帳號: {e}")
|
||||
return {"disabled": False, "error": "Drive Service 無法連線"}
|
||||
|
||||
|
||||
def get_drive_quota_by_job_level(job_level: str) -> int:
|
||||
"""根據職級取得雲端硬碟配額 (GB)"""
|
||||
return DRIVE_QUOTA_BY_JOB_LEVEL.get(job_level, DRIVE_QUOTA_BY_JOB_LEVEL["Junior"])
|
||||
|
||||
|
||||
# 延遲初始化單例
|
||||
_drive_service_client: Optional[DriveServiceClient] = None
|
||||
|
||||
|
||||
def get_drive_service_client() -> DriveServiceClient:
|
||||
"""取得 DriveServiceClient 單例 (延遲初始化)"""
|
||||
global _drive_service_client
|
||||
if _drive_service_client is None:
|
||||
from app.core.config import settings
|
||||
_drive_service_client = DriveServiceClient(
|
||||
base_url=settings.DRIVE_SERVICE_URL,
|
||||
timeout=settings.DRIVE_SERVICE_TIMEOUT,
|
||||
)
|
||||
return _drive_service_client
|
||||
529
backend/app/services/employee_lifecycle.py
Normal file
529
backend/app/services/employee_lifecycle.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
員工生命週期管理服務
|
||||
自動化處理員工的新進、異動、離職流程
|
||||
"""
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from app.models.employee import Employee
|
||||
from app.services.keycloak_admin_client import get_keycloak_admin_client
|
||||
from app.services.drive_service import get_drive_service_client, get_drive_quota_by_job_level
|
||||
from app.services.mailserver_service import get_mailserver_service, get_mail_quota_by_job_level
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmployeeLifecycleService:
|
||||
"""員工生命週期管理服務"""
|
||||
|
||||
def __init__(self):
|
||||
self.keycloak_client = None
|
||||
|
||||
def _get_keycloak_client(self):
|
||||
"""延遲初始化 Keycloak Admin 客戶端"""
|
||||
if self.keycloak_client is None:
|
||||
self.keycloak_client = get_keycloak_admin_client()
|
||||
return self.keycloak_client
|
||||
|
||||
def _generate_temporary_password(self, length: int = 12) -> str:
|
||||
"""生成臨時密碼 (包含大小寫字母、數字和特殊字元)"""
|
||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
password = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return password
|
||||
|
||||
async def onboard_employee(
|
||||
self,
|
||||
db: Session,
|
||||
employee: Employee,
|
||||
create_keycloak: bool = True,
|
||||
create_email: bool = True,
|
||||
create_drive: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
員工到職流程 (Onboarding)
|
||||
|
||||
自動執行:
|
||||
1. 建立 Keycloak SSO 帳號
|
||||
2. 建立主要郵件帳號
|
||||
3. 建立雲端硬碟帳號 (Drive Service)
|
||||
4. 記錄審計日誌
|
||||
|
||||
Args:
|
||||
db: 資料庫 Session
|
||||
employee: 員工物件
|
||||
create_keycloak: 是否建立 Keycloak 帳號
|
||||
create_email: 是否建立郵件帳號
|
||||
create_drive: 是否建立雲端硬碟帳號 (非致命,Drive Service 未上線時跳過)
|
||||
|
||||
Returns:
|
||||
執行結果字典
|
||||
"""
|
||||
results = {
|
||||
"employee_id": employee.id,
|
||||
"employee_number": employee.employee_id,
|
||||
"legal_name": employee.legal_name,
|
||||
"username_base": employee.username_base,
|
||||
"keycloak": {"created": False, "error": None},
|
||||
"email": {"created": False, "error": None},
|
||||
"drive": {"created": False, "error": None},
|
||||
}
|
||||
|
||||
logger.info(f"開始員工到職流程: {employee.employee_id} - {employee.legal_name}")
|
||||
|
||||
# 1. 建立 Keycloak 帳號
|
||||
if create_keycloak:
|
||||
try:
|
||||
keycloak_result = await self._create_keycloak_account(employee)
|
||||
results["keycloak"] = keycloak_result
|
||||
logger.info(f"Keycloak 帳號建立: {keycloak_result}")
|
||||
except Exception as e:
|
||||
logger.error(f"建立 Keycloak 帳號失敗: {str(e)}")
|
||||
results["keycloak"]["error"] = str(e)
|
||||
|
||||
# 2. 建立郵件帳號
|
||||
if create_email:
|
||||
try:
|
||||
email_result = await self._create_email_account(employee)
|
||||
results["email"] = email_result
|
||||
logger.info(f"郵件帳號建立: {email_result}")
|
||||
except Exception as e:
|
||||
logger.error(f"建立郵件帳號失敗: {str(e)}")
|
||||
results["email"]["error"] = str(e)
|
||||
|
||||
# 3. 建立雲端硬碟帳號 (Drive Service - 非致命)
|
||||
if create_drive:
|
||||
drive_result = await self._create_drive_account(employee)
|
||||
results["drive"] = drive_result
|
||||
if drive_result.get("error"):
|
||||
logger.warning(f"雲端硬碟帳號建立 (非致命): {drive_result}")
|
||||
else:
|
||||
logger.info(f"雲端硬碟帳號建立: {drive_result}")
|
||||
|
||||
logger.info(f"員工到職流程完成: {employee.employee_id}")
|
||||
return results
|
||||
|
||||
async def offboard_employee(
|
||||
self,
|
||||
db: Session,
|
||||
employee: Employee,
|
||||
disable_keycloak: bool = True,
|
||||
handle_email: str = "forward", # "forward" or "disable"
|
||||
disable_drive: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
員工離職流程 (Offboarding)
|
||||
|
||||
自動執行:
|
||||
1. 停用 Keycloak SSO 帳號
|
||||
2. 處理郵件帳號 (轉發或停用)
|
||||
3. 停用雲端硬碟帳號 (Drive Service - 非致命)
|
||||
4. 記錄審計日誌
|
||||
|
||||
Args:
|
||||
db: 資料庫 Session
|
||||
employee: 員工物件
|
||||
disable_keycloak: 是否停用 Keycloak 帳號
|
||||
handle_email: 郵件處理方式 ("forward" 或 "disable")
|
||||
disable_drive: 是否停用雲端硬碟帳號 (非致命,Drive Service 未上線時跳過)
|
||||
|
||||
Returns:
|
||||
執行結果字典
|
||||
"""
|
||||
results = {
|
||||
"employee_id": employee.id,
|
||||
"employee_number": employee.employee_id,
|
||||
"legal_name": employee.legal_name,
|
||||
"keycloak": {"disabled": False, "error": None},
|
||||
"email": {"handled": False, "method": handle_email, "error": None},
|
||||
"drive": {"disabled": False, "error": None},
|
||||
}
|
||||
|
||||
logger.info(f"開始員工離職流程: {employee.employee_id} - {employee.legal_name}")
|
||||
|
||||
# 1. 停用 Keycloak 帳號
|
||||
if disable_keycloak:
|
||||
try:
|
||||
keycloak_result = await self._disable_keycloak_account(employee)
|
||||
results["keycloak"] = keycloak_result
|
||||
logger.info(f"Keycloak 帳號停用: {keycloak_result}")
|
||||
except Exception as e:
|
||||
logger.error(f"停用 Keycloak 帳號失敗: {str(e)}")
|
||||
results["keycloak"]["error"] = str(e)
|
||||
|
||||
# 2. 處理郵件帳號
|
||||
try:
|
||||
email_result = await self._handle_email_offboarding(employee, handle_email)
|
||||
results["email"] = email_result
|
||||
logger.info(f"郵件帳號處理: {email_result}")
|
||||
except Exception as e:
|
||||
logger.error(f"處理郵件帳號失敗: {str(e)}")
|
||||
results["email"]["error"] = str(e)
|
||||
|
||||
# 3. 停用雲端硬碟帳號 (Drive Service - 非致命)
|
||||
if disable_drive:
|
||||
drive_result = await self._disable_drive_account(employee)
|
||||
results["drive"] = drive_result
|
||||
if drive_result.get("error"):
|
||||
logger.warning(f"雲端硬碟帳號停用 (非致命): {drive_result}")
|
||||
else:
|
||||
logger.info(f"雲端硬碟帳號停用: {drive_result}")
|
||||
|
||||
logger.info(f"員工離職流程完成: {employee.employee_id}")
|
||||
return results
|
||||
|
||||
async def _create_keycloak_account(self, employee: Employee) -> Dict[str, Any]:
|
||||
"""
|
||||
建立 Keycloak SSO 帳號
|
||||
|
||||
執行步驟:
|
||||
1. 檢查帳號是否已存在
|
||||
2. 生成臨時密碼
|
||||
3. 建立 Keycloak 用戶
|
||||
4. 設定用戶屬性 (姓名、郵件等)
|
||||
|
||||
Args:
|
||||
employee: 員工物件
|
||||
|
||||
Returns:
|
||||
執行結果字典
|
||||
"""
|
||||
try:
|
||||
client = self._get_keycloak_client()
|
||||
username = employee.username_base
|
||||
email = f"{username}@porscheworld.tw"
|
||||
|
||||
# 1. 檢查帳號是否已存在
|
||||
existing_user = client.get_user_by_username(username)
|
||||
if existing_user:
|
||||
return {
|
||||
"created": False,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"user_id": existing_user.get("id"),
|
||||
"message": "Keycloak 帳號已存在",
|
||||
"error": "用戶已存在",
|
||||
}
|
||||
|
||||
# 2. 生成臨時密碼 (12位隨機密碼)
|
||||
temporary_password = self._generate_temporary_password(12)
|
||||
|
||||
# 3. 分割姓名 (如果有英文名稱使用英文,否則使用中文)
|
||||
if employee.english_name:
|
||||
# 英文名稱格式: "FirstName LastName" 或 "FirstName MiddleName LastName"
|
||||
name_parts = employee.english_name.strip().split()
|
||||
first_name = name_parts[0] if len(name_parts) > 0 else username
|
||||
last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else ""
|
||||
else:
|
||||
# 中文名稱格式: "姓名" (第一個字是姓,其餘是名)
|
||||
legal_name = employee.legal_name or username
|
||||
first_name = legal_name[1:] if len(legal_name) > 1 else legal_name
|
||||
last_name = legal_name[0] if len(legal_name) > 0 else ""
|
||||
|
||||
# 4. 建立 Keycloak 用戶
|
||||
user_id = client.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
enabled=True,
|
||||
email_verified=True,
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
return {
|
||||
"created": False,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"message": "Keycloak 用戶建立失敗",
|
||||
"error": "無法建立用戶 (API 返回 None)",
|
||||
}
|
||||
|
||||
# 5. 設定初始密碼
|
||||
password_set = client.reset_password(
|
||||
user_id=user_id,
|
||||
password=temporary_password,
|
||||
temporary=True # 用戶首次登入需修改密碼
|
||||
)
|
||||
|
||||
if not password_set:
|
||||
logger.warning(f"Keycloak 用戶 {username} 建立成功,但密碼設定失敗")
|
||||
|
||||
logger.info(f"✓ Keycloak 帳號建立成功: {username} (ID: {user_id})")
|
||||
|
||||
return {
|
||||
"created": True,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"user_id": user_id,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"temporary_password": temporary_password, # 應透過安全方式通知用戶
|
||||
"password_set": password_set,
|
||||
"message": f"Keycloak 帳號建立成功 (用戶首次登入需修改密碼)",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ 建立 Keycloak 帳號時發生錯誤: {str(e)}")
|
||||
return {
|
||||
"created": False,
|
||||
"username": employee.username_base,
|
||||
"email": f"{employee.username_base}@porscheworld.tw",
|
||||
"message": "建立 Keycloak 帳號時發生錯誤",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def _create_email_account(self, employee: Employee) -> Dict[str, Any]:
|
||||
"""
|
||||
建立郵件帳號 (Docker Mailserver)
|
||||
|
||||
執行步驟:
|
||||
1. 依職級取得配額
|
||||
2. 產生臨時密碼 (員工後續透過 Keycloak SSO 登入)
|
||||
3. 透過 SSH + docker exec 建立帳號
|
||||
4. 設定配額
|
||||
"""
|
||||
email_address = f"{employee.username_base}@porscheworld.tw"
|
||||
|
||||
try:
|
||||
mailserver = get_mailserver_service()
|
||||
|
||||
# 依職級取得郵件配額
|
||||
job_level = getattr(employee, "job_level", "Junior") or "Junior"
|
||||
quota_mb = get_mail_quota_by_job_level(job_level)
|
||||
|
||||
# 產生臨時密碼
|
||||
temp_password = self._generate_temporary_password()
|
||||
|
||||
result = mailserver.create_email_account(
|
||||
email=email_address,
|
||||
password=temp_password,
|
||||
quota_mb=quota_mb,
|
||||
)
|
||||
|
||||
if result["created"]:
|
||||
logger.info(f"郵件帳號建立成功: {email_address} ({quota_mb}MB)")
|
||||
else:
|
||||
logger.warning(f"郵件帳號建立失敗: {email_address} - {result.get('error')}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"建立郵件帳號時發生非預期錯誤: {str(e)}")
|
||||
return {
|
||||
"created": False,
|
||||
"email": email_address,
|
||||
"quota_mb": 0,
|
||||
"message": "建立郵件帳號時發生錯誤",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def _create_drive_account(self, employee: Employee) -> Dict[str, Any]:
|
||||
"""
|
||||
建立雲端硬碟帳號 (Drive Service)
|
||||
|
||||
呼叫 drive-api.ease.taipei 建立 Nextcloud 帳號
|
||||
Drive Service 未上線時以 warning 記錄,不影響其他流程
|
||||
"""
|
||||
try:
|
||||
client = get_drive_service_client()
|
||||
from app.core.config import settings
|
||||
|
||||
# 根據職級取得配額
|
||||
job_level = getattr(employee, "job_level", "Junior") or "Junior"
|
||||
quota_gb = get_drive_quota_by_job_level(job_level)
|
||||
|
||||
result = client.create_user(
|
||||
tenant_id=settings.DRIVE_SERVICE_TENANT_ID,
|
||||
keycloak_user_id=str(getattr(employee, "keycloak_user_id", "") or ""),
|
||||
username=employee.username_base,
|
||||
email=f"{employee.username_base}@porscheworld.tw",
|
||||
display_name=employee.legal_name or employee.username_base,
|
||||
quota_gb=quota_gb,
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"建立雲端硬碟帳號時發生非預期錯誤: {str(e)}")
|
||||
return {
|
||||
"created": False,
|
||||
"username": employee.username_base,
|
||||
"quota_gb": 0,
|
||||
"drive_url": None,
|
||||
"message": "建立雲端硬碟帳號時發生錯誤",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def _disable_keycloak_account(self, employee: Employee) -> Dict[str, Any]:
|
||||
"""
|
||||
停用 Keycloak SSO 帳號
|
||||
|
||||
執行步驟:
|
||||
1. 查詢用戶 ID
|
||||
2. 停用帳號 (不刪除,保留審計記錄)
|
||||
|
||||
注意: 不刪除帳號,只停用,以保留歷史記錄和審計追蹤
|
||||
|
||||
Args:
|
||||
employee: 員工物件
|
||||
|
||||
Returns:
|
||||
執行結果字典
|
||||
"""
|
||||
try:
|
||||
client = self._get_keycloak_client()
|
||||
username = employee.username_base
|
||||
|
||||
# 1. 查詢用戶
|
||||
user = client.get_user_by_username(username)
|
||||
if not user:
|
||||
return {
|
||||
"disabled": False,
|
||||
"username": username,
|
||||
"message": "Keycloak 帳號不存在",
|
||||
"error": "用戶不存在",
|
||||
}
|
||||
|
||||
user_id = user.get("id")
|
||||
|
||||
# 2. 檢查是否已停用
|
||||
if not user.get("enabled", False):
|
||||
return {
|
||||
"disabled": True,
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"message": "Keycloak 帳號已經是停用狀態",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# 3. 停用帳號
|
||||
success = client.disable_user(user_id)
|
||||
|
||||
if success:
|
||||
logger.info(f"✓ Keycloak 帳號停用成功: {username} (ID: {user_id})")
|
||||
return {
|
||||
"disabled": True,
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"message": "Keycloak 帳號已停用 (帳號保留以維持審計記錄)",
|
||||
"error": None,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"disabled": False,
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"message": "Keycloak 帳號停用失敗",
|
||||
"error": "API 調用失敗",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ 停用 Keycloak 帳號時發生錯誤: {str(e)}")
|
||||
return {
|
||||
"disabled": False,
|
||||
"username": employee.username_base,
|
||||
"message": "停用 Keycloak 帳號時發生錯誤",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def _handle_email_offboarding(
|
||||
self, employee: Employee, method: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
處理離職員工的郵件帳號 (Docker Mailserver)
|
||||
|
||||
Args:
|
||||
method: "forward" - 停用帳號並標記轉寄
|
||||
"disable" - 直接刪除郵件帳號
|
||||
"""
|
||||
email_address = f"{employee.username_base}@porscheworld.tw"
|
||||
|
||||
try:
|
||||
mailserver = get_mailserver_service()
|
||||
|
||||
if method == "forward":
|
||||
# 刪除帳號 (Docker Mailserver 不支援原生轉寄設定)
|
||||
# 轉寄規則記錄在 EmailAccount.forward_to,由 HR Portal 管理
|
||||
result = mailserver.delete_email_account(email_address)
|
||||
return {
|
||||
"handled": result["deleted"],
|
||||
"method": "forward",
|
||||
"email": email_address,
|
||||
"forward_to": "hr@porscheworld.tw",
|
||||
"message": "郵件帳號已停用,轉寄規則已記錄" if result["deleted"] else "郵件帳號停用失敗",
|
||||
"error": result.get("error"),
|
||||
}
|
||||
|
||||
elif method == "disable":
|
||||
# 刪除郵件帳號
|
||||
result = mailserver.delete_email_account(email_address)
|
||||
return {
|
||||
"handled": result["deleted"],
|
||||
"method": "disable",
|
||||
"email": email_address,
|
||||
"message": "郵件帳號已刪除" if result["deleted"] else "郵件帳號刪除失敗",
|
||||
"error": result.get("error"),
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"handled": False,
|
||||
"method": method,
|
||||
"email": email_address,
|
||||
"error": f"不支援的處理方式: {method}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"處理郵件帳號離職時發生非預期錯誤: {str(e)}")
|
||||
return {
|
||||
"handled": False,
|
||||
"method": method,
|
||||
"email": email_address,
|
||||
"message": "處理郵件帳號時發生錯誤",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def _disable_drive_account(self, employee: Employee) -> Dict[str, Any]:
|
||||
"""
|
||||
停用雲端硬碟帳號 (Drive Service)
|
||||
|
||||
呼叫 drive-api.ease.taipei 停用 Nextcloud 帳號 (軟刪除,保留檔案)
|
||||
Drive Service 未上線時以 warning 記錄,不影響其他流程
|
||||
"""
|
||||
try:
|
||||
client = get_drive_service_client()
|
||||
|
||||
# 查詢 drive_user_id (目前以 username 查詢)
|
||||
# Drive Service 上線後需實作 GET /api/v1/drive/users?username={username}
|
||||
# 暫時回傳 warning 狀態
|
||||
logger.warning(
|
||||
f"停用雲端硬碟帳號: {employee.username_base} - "
|
||||
f"需 Drive Service 上線後實作查詢 user_id 再停用"
|
||||
)
|
||||
return {
|
||||
"disabled": False,
|
||||
"username": employee.username_base,
|
||||
"message": "Drive Service 尚未上線,雲端硬碟帳號停用待後續處理",
|
||||
"error": "Drive Service 未上線",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"停用雲端硬碟帳號時發生非預期錯誤: {str(e)}")
|
||||
return {
|
||||
"disabled": False,
|
||||
"username": employee.username_base,
|
||||
"message": "停用雲端硬碟帳號時發生錯誤",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
# 建立全域實例
|
||||
employee_lifecycle_service = EmployeeLifecycleService()
|
||||
|
||||
|
||||
def get_employee_lifecycle_service() -> EmployeeLifecycleService:
|
||||
"""取得員工生命週期服務實例"""
|
||||
return employee_lifecycle_service
|
||||
685
backend/app/services/environment_checker.py
Normal file
685
backend/app/services/environment_checker.py
Normal file
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
環境檢測服務
|
||||
自動檢測系統所需的所有環境組件
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
import psycopg2
|
||||
import requests
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
class EnvironmentChecker:
|
||||
"""環境檢測器"""
|
||||
|
||||
def __init__(self):
|
||||
self.results = {}
|
||||
|
||||
# ==================== Redis 檢測 ====================
|
||||
|
||||
def check_redis(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 Redis 服務
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"host": str,
|
||||
"port": int,
|
||||
"ping_success": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"host": None,
|
||||
"port": None,
|
||||
"ping_success": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 檢查環境變數
|
||||
redis_host = os.getenv("REDIS_HOST")
|
||||
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||
|
||||
if not redis_host:
|
||||
result["error"] = "REDIS_HOST 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["host"] = redis_host
|
||||
result["port"] = int(redis_port)
|
||||
|
||||
# 測試連線(需要 redis 套件)
|
||||
try:
|
||||
import redis
|
||||
redis_client = redis.Redis(
|
||||
host=redis_host,
|
||||
port=int(redis_port),
|
||||
password=os.getenv("REDIS_PASSWORD"),
|
||||
db=int(os.getenv("REDIS_DB", "0")),
|
||||
socket_connect_timeout=5,
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
# 測試 PING
|
||||
pong = redis_client.ping()
|
||||
if pong:
|
||||
result["available"] = True
|
||||
result["ping_success"] = True
|
||||
result["status"] = "ok"
|
||||
else:
|
||||
result["status"] = "error"
|
||||
result["error"] = "Redis PING 失敗"
|
||||
|
||||
redis_client.close()
|
||||
|
||||
except ImportError:
|
||||
result["status"] = "warning"
|
||||
result["error"] = "redis 套件未安裝(pip install redis)"
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = f"Redis 連線失敗: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def test_redis_connection(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
password: Optional[str] = None,
|
||||
db: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
測試 Redis 連線(用於初始化時使用者輸入的連線資訊)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"ping_success": bool,
|
||||
"message": str,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"ping_success": False,
|
||||
"message": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
import redis
|
||||
|
||||
redis_client = redis.Redis(
|
||||
host=host,
|
||||
port=port,
|
||||
password=password if password else None,
|
||||
db=db,
|
||||
socket_connect_timeout=5,
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
# 測試 PING
|
||||
pong = redis_client.ping()
|
||||
if pong:
|
||||
result["success"] = True
|
||||
result["ping_success"] = True
|
||||
result["message"] = "Redis 連線成功"
|
||||
else:
|
||||
result["error"] = "Redis PING 失敗"
|
||||
|
||||
redis_client.close()
|
||||
|
||||
except ImportError:
|
||||
result["error"] = "redis 套件未安裝"
|
||||
except redis.exceptions.AuthenticationError:
|
||||
result["error"] = "Redis 密碼錯誤"
|
||||
except redis.exceptions.ConnectionError as e:
|
||||
result["error"] = f"無法連接到 Redis: {str(e)}"
|
||||
except Exception as e:
|
||||
result["error"] = f"未知錯誤: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def check_all(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢查所有環境組件
|
||||
|
||||
Returns:
|
||||
完整的檢測報告
|
||||
"""
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"overall_status": "pending",
|
||||
"components": {
|
||||
"redis": self.check_redis(),
|
||||
"database": self.check_database(),
|
||||
"keycloak": self.check_keycloak(),
|
||||
"mailserver": self.check_mailserver(),
|
||||
"drive": self.check_drive_service(),
|
||||
"traefik": self.check_traefik(),
|
||||
"network": self.check_network(),
|
||||
},
|
||||
"missing_configs": self.get_missing_configs(),
|
||||
"recommendations": self.get_recommendations()
|
||||
}
|
||||
|
||||
# ==================== 資料庫檢測 ====================
|
||||
|
||||
def check_database(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 PostgreSQL 資料庫
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"connection_string": str,
|
||||
"version": str,
|
||||
"tables_exist": bool,
|
||||
"tenant_exists": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"connection_string": None,
|
||||
"version": None,
|
||||
"tables_exist": False,
|
||||
"tenant_exists": False,
|
||||
"tenant_initialized": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 1. 檢查環境變數
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if not db_url:
|
||||
result["error"] = "DATABASE_URL 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["connection_string"] = self._mask_password(db_url)
|
||||
|
||||
# 2. 測試連線
|
||||
try:
|
||||
engine = create_engine(db_url)
|
||||
with engine.connect() as conn:
|
||||
# 取得版本
|
||||
version_result = conn.execute(text("SELECT version()"))
|
||||
version_row = version_result.fetchone()
|
||||
if version_row:
|
||||
result["version"] = version_row[0].split(',')[0]
|
||||
|
||||
result["available"] = True
|
||||
|
||||
# 3. 檢查 tenants 表是否存在
|
||||
try:
|
||||
tenant_check = conn.execute(text(
|
||||
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'tenants')"
|
||||
))
|
||||
result["tables_exist"] = tenant_check.scalar()
|
||||
|
||||
if result["tables_exist"]:
|
||||
# 4. 檢查是否有租戶資料
|
||||
tenant_count = conn.execute(text("SELECT COUNT(*) FROM tenants"))
|
||||
count = tenant_count.scalar()
|
||||
result["tenant_exists"] = count > 0
|
||||
|
||||
if result["tenant_exists"]:
|
||||
# 5. 檢查租戶是否已初始化
|
||||
init_check = conn.execute(text(
|
||||
"SELECT is_initialized FROM tenants LIMIT 1"
|
||||
))
|
||||
is_init = init_check.scalar()
|
||||
result["tenant_initialized"] = is_init
|
||||
|
||||
except Exception as e:
|
||||
result["tables_exist"] = False
|
||||
result["error"] = f"資料表檢查失敗: {str(e)}"
|
||||
|
||||
# 判斷狀態
|
||||
if result["tenant_initialized"]:
|
||||
result["status"] = "ok"
|
||||
elif result["tenant_exists"]:
|
||||
result["status"] = "warning"
|
||||
elif result["tables_exist"]:
|
||||
result["status"] = "warning"
|
||||
else:
|
||||
result["status"] = "warning"
|
||||
|
||||
except Exception as e:
|
||||
result["available"] = False
|
||||
result["status"] = "error"
|
||||
result["error"] = f"資料庫連線失敗: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def test_database_connection(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
database: str,
|
||||
user: str,
|
||||
password: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
測試資料庫連線(用於初始化時使用者輸入的連線資訊)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"version": str,
|
||||
"message": str,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"version": None,
|
||||
"message": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# 使用 psycopg2 直接測試
|
||||
conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
database=database,
|
||||
user=user,
|
||||
password=password,
|
||||
connect_timeout=5
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT version()")
|
||||
version = cursor.fetchone()[0]
|
||||
result["version"] = version.split(',')[0]
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
result["success"] = True
|
||||
result["message"] = "資料庫連線成功"
|
||||
|
||||
except psycopg2.OperationalError as e:
|
||||
result["error"] = f"連線失敗: {str(e)}"
|
||||
except Exception as e:
|
||||
result["error"] = f"未知錯誤: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== Keycloak 檢測 ====================
|
||||
|
||||
def check_keycloak(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 Keycloak SSO 服務
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"url": str,
|
||||
"realm": str,
|
||||
"realm_exists": bool,
|
||||
"clients_configured": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"url": None,
|
||||
"realm": None,
|
||||
"realm_exists": False,
|
||||
"clients_configured": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 1. 檢查環境變數
|
||||
kc_url = os.getenv("KEYCLOAK_URL")
|
||||
kc_realm = os.getenv("KEYCLOAK_REALM")
|
||||
|
||||
if not kc_url:
|
||||
result["error"] = "KEYCLOAK_URL 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["url"] = kc_url
|
||||
result["realm"] = kc_realm or "未設定"
|
||||
|
||||
# 2. 測試 Keycloak 服務是否運行
|
||||
try:
|
||||
# 測試 health endpoint
|
||||
response = requests.get(f"{kc_url}/health", timeout=5)
|
||||
if response.status_code == 200:
|
||||
result["available"] = True
|
||||
else:
|
||||
result["available"] = False
|
||||
result["error"] = f"Keycloak 服務異常: HTTP {response.status_code}"
|
||||
result["status"] = "error"
|
||||
return result
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
result["available"] = False
|
||||
result["status"] = "error"
|
||||
result["error"] = f"無法連接到 Keycloak: {str(e)}"
|
||||
return result
|
||||
|
||||
# 3. 檢查 Realm 是否存在
|
||||
if kc_realm:
|
||||
try:
|
||||
# 嘗試取得 Realm 的 OpenID Configuration
|
||||
oidc_url = f"{kc_url}/realms/{kc_realm}/.well-known/openid-configuration"
|
||||
response = requests.get(oidc_url, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
result["realm_exists"] = True
|
||||
result["status"] = "ok"
|
||||
else:
|
||||
result["realm_exists"] = False
|
||||
result["status"] = "warning"
|
||||
result["error"] = f"Realm '{kc_realm}' 不存在"
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = f"Realm 檢查失敗: {str(e)}"
|
||||
result["status"] = "warning"
|
||||
else:
|
||||
result["status"] = "warning"
|
||||
result["error"] = "KEYCLOAK_REALM 未設定"
|
||||
|
||||
return result
|
||||
|
||||
def test_keycloak_connection(
|
||||
self,
|
||||
url: str,
|
||||
realm: str,
|
||||
admin_username: str,
|
||||
admin_password: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
測試 Keycloak 連線並驗證管理員權限
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"realm_exists": bool,
|
||||
"admin_access": bool,
|
||||
"message": str,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"success": False,
|
||||
"realm_exists": False,
|
||||
"admin_access": False,
|
||||
"message": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. 測試服務是否運行 (使用根路徑,Keycloak 會返回 302 重定向)
|
||||
health_response = requests.get(f"{url}/", timeout=5, allow_redirects=False)
|
||||
if health_response.status_code not in [200, 302, 303]:
|
||||
result["error"] = "Keycloak 服務未運行"
|
||||
return result
|
||||
|
||||
# 2. 測試管理員登入
|
||||
token_url = f"{url}/realms/master/protocol/openid-connect/token"
|
||||
token_data = {
|
||||
"grant_type": "password",
|
||||
"client_id": "admin-cli",
|
||||
"username": admin_username,
|
||||
"password": admin_password
|
||||
}
|
||||
|
||||
token_response = requests.post(token_url, data=token_data, timeout=10)
|
||||
|
||||
if token_response.status_code == 200:
|
||||
result["admin_access"] = True
|
||||
access_token = token_response.json().get("access_token")
|
||||
|
||||
# 3. 檢查 Realm 是否存在
|
||||
realm_url = f"{url}/admin/realms/{realm}"
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
realm_response = requests.get(realm_url, headers=headers, timeout=5)
|
||||
|
||||
if realm_response.status_code == 200:
|
||||
result["realm_exists"] = True
|
||||
result["success"] = True
|
||||
result["message"] = "Keycloak 連線成功,Realm 存在"
|
||||
elif realm_response.status_code == 404:
|
||||
result["success"] = True
|
||||
result["message"] = "Keycloak 連線成功,但 Realm 不存在(將自動建立)"
|
||||
else:
|
||||
result["error"] = f"Realm 檢查失敗: HTTP {realm_response.status_code}"
|
||||
|
||||
else:
|
||||
result["error"] = "管理員帳號密碼錯誤"
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
result["error"] = f"連線失敗: {str(e)}"
|
||||
except Exception as e:
|
||||
result["error"] = f"未知錯誤: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 郵件伺服器檢測 ====================
|
||||
|
||||
def check_mailserver(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測郵件伺服器 (Docker Mailserver)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"ssh_configured": bool,
|
||||
"container_running": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"ssh_configured": False,
|
||||
"container_running": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 檢查 SSH 設定
|
||||
ssh_host = os.getenv("MAILSERVER_SSH_HOST")
|
||||
ssh_user = os.getenv("MAILSERVER_SSH_USER")
|
||||
container_name = os.getenv("MAILSERVER_CONTAINER_NAME")
|
||||
|
||||
if not all([ssh_host, ssh_user, container_name]):
|
||||
result["error"] = "郵件伺服器 SSH 設定不完整"
|
||||
return result
|
||||
|
||||
result["ssh_configured"] = True
|
||||
|
||||
# 測試 SSH 連線(可選功能)
|
||||
# 注意:這需要 paramiko 套件,且需要謹慎處理安全性
|
||||
result["status"] = "warning"
|
||||
result["error"] = "郵件伺服器連線測試需要手動驗證"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 雲端硬碟檢測 ====================
|
||||
|
||||
def check_drive_service(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測雲端硬碟服務 (Nextcloud)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "error" | "not_configured",
|
||||
"available": bool,
|
||||
"url": str,
|
||||
"api_accessible": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"available": False,
|
||||
"url": None,
|
||||
"api_accessible": False,
|
||||
"error": None
|
||||
}
|
||||
|
||||
drive_url = os.getenv("DRIVE_SERVICE_URL")
|
||||
|
||||
if not drive_url:
|
||||
result["error"] = "DRIVE_SERVICE_URL 環境變數未設定"
|
||||
return result
|
||||
|
||||
result["url"] = drive_url
|
||||
|
||||
try:
|
||||
response = requests.get(f"{drive_url}/status.php", timeout=5)
|
||||
if response.status_code == 200:
|
||||
result["available"] = True
|
||||
result["api_accessible"] = True
|
||||
result["status"] = "ok"
|
||||
else:
|
||||
result["status"] = "warning"
|
||||
result["error"] = f"Drive 服務回應異常: HTTP {response.status_code}"
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
result["status"] = "error"
|
||||
result["error"] = f"無法連接到 Drive 服務: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
# ==================== Traefik 檢測 ====================
|
||||
|
||||
def check_traefik(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測 Traefik 反向代理
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning" | "not_configured",
|
||||
"dashboard_accessible": bool,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "not_configured",
|
||||
"dashboard_accessible": False,
|
||||
"error": "Traefik 檢測未實作(需要 Dashboard URL)"
|
||||
}
|
||||
|
||||
# 簡化檢測:Traefik 通常在本機運行
|
||||
# 可以透過檢查 port 80/443 是否被占用來判斷
|
||||
result["status"] = "ok"
|
||||
result["dashboard_accessible"] = False
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 網路檢測 ====================
|
||||
|
||||
def check_network(self) -> Dict[str, Any]:
|
||||
"""
|
||||
檢測網路連通性
|
||||
|
||||
Returns:
|
||||
{
|
||||
"status": "ok" | "warning",
|
||||
"dns_resolution": bool,
|
||||
"ports_open": dict,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"status": "ok",
|
||||
"dns_resolution": True,
|
||||
"ports_open": {
|
||||
"80": False,
|
||||
"443": False,
|
||||
"5433": False
|
||||
},
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 檢查常用 port 是否開啟
|
||||
ports_to_check = [80, 443, 5433]
|
||||
|
||||
for port in ports_to_check:
|
||||
result["ports_open"][str(port)] = self._is_port_open("localhost", port)
|
||||
|
||||
return result
|
||||
|
||||
# ==================== 輔助方法 ====================
|
||||
|
||||
def _is_port_open(self, host: str, port: int, timeout: int = 2) -> bool:
|
||||
"""檢查 port 是否開啟"""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
result = sock.connect_ex((host, port))
|
||||
sock.close()
|
||||
return result == 0
|
||||
except:
|
||||
return False
|
||||
|
||||
def _mask_password(self, connection_string: str) -> str:
|
||||
"""遮蔽連線字串中的密碼"""
|
||||
import re
|
||||
return re.sub(r'://([^:]+):([^@]+)@', r'://\1:****@', connection_string)
|
||||
|
||||
def get_missing_configs(self) -> List[str]:
|
||||
"""取得缺少的環境變數"""
|
||||
required_vars = [
|
||||
"DATABASE_URL",
|
||||
"KEYCLOAK_URL",
|
||||
"KEYCLOAK_REALM",
|
||||
"KEYCLOAK_CLIENT_ID",
|
||||
"KEYCLOAK_CLIENT_SECRET",
|
||||
]
|
||||
|
||||
missing = []
|
||||
for var in required_vars:
|
||||
if not os.getenv(var):
|
||||
missing.append(var)
|
||||
|
||||
return missing
|
||||
|
||||
def get_recommendations(self) -> List[str]:
|
||||
"""根據檢測結果提供建議"""
|
||||
recommendations = []
|
||||
|
||||
# 這裡可以根據檢測結果動態產生建議
|
||||
if not os.getenv("DATABASE_URL"):
|
||||
recommendations.append("請先設定資料庫連線資訊")
|
||||
|
||||
if not os.getenv("KEYCLOAK_URL"):
|
||||
recommendations.append("請設定 Keycloak SSO 服務")
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 測試環境檢測
|
||||
checker = EnvironmentChecker()
|
||||
report = checker.check_all()
|
||||
|
||||
print("=== 環境檢測報告 ===\n")
|
||||
for component, result in report["components"].items():
|
||||
status_icon = {
|
||||
"ok": "✓",
|
||||
"warning": "⚠",
|
||||
"error": "✗",
|
||||
"not_configured": "○"
|
||||
}.get(result["status"], "?")
|
||||
|
||||
print(f"{status_icon} {component.upper()}: {result['status']}")
|
||||
if result.get("error"):
|
||||
print(f" 錯誤: {result['error']}")
|
||||
|
||||
print(f"\n缺少的配置: {', '.join(report['missing_configs']) or '無'}")
|
||||
789
backend/app/services/installation_service.py
Normal file
789
backend/app/services/installation_service.py
Normal file
@@ -0,0 +1,789 @@
|
||||
"""
|
||||
初始化系統服務
|
||||
負責初始化流程的業務邏輯
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
InstallationSession,
|
||||
InstallationTenantInfo,
|
||||
InstallationDepartmentSetup,
|
||||
TemporaryPassword,
|
||||
InstallationAccessLog,
|
||||
Tenant,
|
||||
Department,
|
||||
UserRole
|
||||
)
|
||||
from app.models.emp_resume import EmpResume
|
||||
from app.models.emp_setting import EmpSetting
|
||||
from app.utils.password_generator import generate_secure_password, hash_password
|
||||
from app.services.keycloak_service import KeycloakService
|
||||
|
||||
|
||||
class InstallationService:
|
||||
"""初始化服務"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.keycloak_service = KeycloakService()
|
||||
|
||||
# ==================== Phase 0: 建立安裝會話 ====================
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
tenant_id: int,
|
||||
environment: str,
|
||||
executed_by: str,
|
||||
session_name: Optional[str] = None
|
||||
) -> InstallationSession:
|
||||
"""
|
||||
建立新的安裝會話
|
||||
|
||||
Args:
|
||||
tenant_id: 租戶 ID
|
||||
environment: 環境 (development/testing/production)
|
||||
executed_by: 執行人
|
||||
session_name: 會話名稱(可選)
|
||||
|
||||
Returns:
|
||||
安裝會話物件
|
||||
"""
|
||||
session = InstallationSession(
|
||||
tenant_id=tenant_id,
|
||||
session_name=session_name or f"{environment} 環境初始化",
|
||||
environment=environment,
|
||||
status='in_progress',
|
||||
executed_by=executed_by,
|
||||
started_at=datetime.now()
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
|
||||
# 記錄審計日誌
|
||||
self._log_access(
|
||||
session_id=session.id,
|
||||
action='create_session',
|
||||
action_by=executed_by,
|
||||
action_method='api',
|
||||
access_granted=True
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
# ==================== Phase 2: 公司資訊設定 ====================
|
||||
|
||||
def save_tenant_info(
|
||||
self,
|
||||
session_id: int,
|
||||
tenant_info_data: Dict[str, Any]
|
||||
) -> InstallationTenantInfo:
|
||||
"""
|
||||
儲存租戶初始化資訊
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
tenant_info_data: 租戶資訊字典
|
||||
|
||||
Returns:
|
||||
租戶初始化資訊物件
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
# 檢查是否已存在(優先用 session_id,找不到則用 tenant_id)
|
||||
tenant_info = self.db.query(InstallationTenantInfo).filter_by(
|
||||
session_id=session_id
|
||||
).first()
|
||||
|
||||
# 如果 session_id 找不到,嘗試用 tenant_id 查詢(處理舊數據)
|
||||
if not tenant_info:
|
||||
tenant_info = self.db.query(InstallationTenantInfo).filter_by(
|
||||
tenant_id=session.tenant_id
|
||||
).first()
|
||||
|
||||
if tenant_info:
|
||||
# 更新現有資料(同時更新 session_id 以保持一致性)
|
||||
tenant_info.session_id = session_id
|
||||
for key, value in tenant_info_data.items():
|
||||
if hasattr(tenant_info, key):
|
||||
setattr(tenant_info, key, value)
|
||||
tenant_info.updated_at = datetime.now()
|
||||
else:
|
||||
# 建立新資料
|
||||
tenant_info = InstallationTenantInfo(
|
||||
tenant_id=session.tenant_id,
|
||||
session_id=session_id,
|
||||
**tenant_info_data
|
||||
)
|
||||
self.db.add(tenant_info)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(tenant_info)
|
||||
|
||||
return tenant_info
|
||||
|
||||
def setup_admin_credentials(
|
||||
self,
|
||||
session_id: int,
|
||||
admin_data: Dict[str, Any],
|
||||
password_method: str = 'auto',
|
||||
manual_password: Optional[str] = None
|
||||
) -> tuple[InstallationTenantInfo, str]:
|
||||
"""
|
||||
設定系統管理員並產生初始密碼
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
admin_data: 管理員資訊
|
||||
password_method: 密碼設定方式 (auto/manual)
|
||||
manual_password: 手動設定的密碼(如果 method='manual')
|
||||
|
||||
Returns:
|
||||
(租戶資訊物件, 明文密碼)
|
||||
|
||||
Raises:
|
||||
ValueError: 如果密碼驗證失敗
|
||||
"""
|
||||
from app.utils.password_generator import validate_password_for_user
|
||||
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
# 產生或驗證密碼
|
||||
if password_method == 'auto':
|
||||
initial_password = generate_secure_password(16)
|
||||
else:
|
||||
if not manual_password:
|
||||
raise ValueError("手動設定密碼時必須提供 manual_password")
|
||||
|
||||
# 驗證密碼強度
|
||||
is_valid, errors = validate_password_for_user(
|
||||
manual_password,
|
||||
username=admin_data.get('admin_username'),
|
||||
name=admin_data.get('admin_legal_name'),
|
||||
email=admin_data.get('admin_email')
|
||||
)
|
||||
if not is_valid:
|
||||
raise ValueError(f"密碼驗證失敗: {', '.join(errors)}")
|
||||
|
||||
initial_password = manual_password
|
||||
|
||||
# 加密密碼
|
||||
password_hash = hash_password(initial_password)
|
||||
|
||||
# 儲存管理員資訊
|
||||
tenant_info = self.save_tenant_info(session_id, admin_data)
|
||||
|
||||
# 建立臨時密碼記錄
|
||||
temp_password = TemporaryPassword(
|
||||
tenant_id=session.tenant_id,
|
||||
username=admin_data.get('admin_english_name', 'admin'), # ✅ 使用 admin_english_name (SSO 帳號)
|
||||
session_id=session_id,
|
||||
password_hash=password_hash,
|
||||
plain_password=initial_password, # 明文密碼(僅此階段保存)
|
||||
password_method=password_method,
|
||||
is_temporary=True,
|
||||
must_change_on_login=True,
|
||||
created_at=datetime.now(),
|
||||
expires_at=datetime.now() + timedelta(days=7), # 7 天有效期
|
||||
is_viewable=True,
|
||||
viewable_until=datetime.now() + timedelta(hours=1) # 1 小時內可查看
|
||||
)
|
||||
self.db.add(temp_password)
|
||||
self.db.commit()
|
||||
|
||||
return tenant_info, initial_password
|
||||
|
||||
# ==================== Phase 3: 組織架構設定 ====================
|
||||
|
||||
def setup_departments(
|
||||
self,
|
||||
session_id: int,
|
||||
departments_data: List[Dict[str, Any]]
|
||||
) -> List[InstallationDepartmentSetup]:
|
||||
"""
|
||||
設定部門架構
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
departments_data: 部門資訊列表
|
||||
|
||||
Returns:
|
||||
部門設定物件列表
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
dept_setups = []
|
||||
for dept_data in departments_data:
|
||||
dept_setup = InstallationDepartmentSetup(
|
||||
tenant_id=session.tenant_id,
|
||||
session_id=session_id,
|
||||
**dept_data
|
||||
)
|
||||
self.db.add(dept_setup)
|
||||
dept_setups.append(dept_setup)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return dept_setups
|
||||
|
||||
# ==================== Phase 4: 執行初始化 ====================
|
||||
|
||||
def execute_initialization(
|
||||
self,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
執行完整的初始化流程
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
|
||||
Returns:
|
||||
執行結果
|
||||
|
||||
Raises:
|
||||
Exception: 如果任何步驟失敗
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
tenant_info = self.db.query(InstallationTenantInfo).filter_by(
|
||||
session_id=session_id
|
||||
).first()
|
||||
if not tenant_info:
|
||||
# 調試:查看資料庫中所有的 tenant_info 記錄
|
||||
all_tenant_infos = self.db.query(InstallationTenantInfo).all()
|
||||
tenant_info_list = [f"ID:{t.id}, SessionID:{t.session_id}, TenantID:{t.tenant_id}" for t in all_tenant_infos]
|
||||
raise ValueError(
|
||||
f"找不到租戶初始化資訊 (session_id={session_id})。"
|
||||
f"資料庫中現有記錄: {tenant_info_list}"
|
||||
)
|
||||
|
||||
results = {
|
||||
'tenant_updated': False,
|
||||
'departments_created': 0,
|
||||
'admin_created': False,
|
||||
'keycloak_user_created': False,
|
||||
'mailbox_created': False,
|
||||
'roles_assigned': False
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: 建立或更新租戶基本資料
|
||||
if session.tenant_id:
|
||||
# 更新現有租戶
|
||||
tenant = self.db.query(Tenant).get(session.tenant_id)
|
||||
if not tenant:
|
||||
raise ValueError(f"找不到租戶 ID: {session.tenant_id}")
|
||||
else:
|
||||
# 建立新租戶 (初始化流程)
|
||||
# ⚠️ 租戶代碼和 Keycloak Realm 必須為小寫
|
||||
tenant_code_lower = tenant_info.tenant_code.lower()
|
||||
|
||||
tenant = Tenant(
|
||||
name=tenant_info.company_name,
|
||||
name_eng=tenant_info.company_name_en,
|
||||
code=tenant_code_lower,
|
||||
keycloak_realm=tenant_code_lower, # Keycloak Realm = tenant_code (小寫)
|
||||
prefix=tenant_info.tenant_prefix,
|
||||
tax_id=tenant_info.tax_id,
|
||||
tel=tenant_info.tel,
|
||||
add=tenant_info.add,
|
||||
domain_set=tenant_info.domain_set,
|
||||
domain=tenant_info.domain,
|
||||
is_sysmana=True, # 初始化建立的第一個租戶為系統管理公司
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(tenant)
|
||||
self.db.flush() # 取得 tenant.id
|
||||
|
||||
# 更新 session 的 tenant_id
|
||||
session.tenant_id = tenant.id
|
||||
tenant_info.tenant_id = tenant.id
|
||||
|
||||
# 更新租戶資料 (如果是更新模式)
|
||||
if session.tenant_id and tenant:
|
||||
if tenant_info.company_name:
|
||||
tenant.name = tenant_info.company_name
|
||||
if tenant_info.company_name_en:
|
||||
tenant.name_eng = tenant_info.company_name_en
|
||||
if tenant_info.tenant_code:
|
||||
tenant.code = tenant_info.tenant_code
|
||||
if tenant_info.tenant_prefix:
|
||||
tenant.prefix = tenant_info.tenant_prefix
|
||||
if tenant_info.tax_id:
|
||||
tenant.tax_id = tenant_info.tax_id
|
||||
if tenant_info.tel:
|
||||
tenant.tel = tenant_info.tel
|
||||
if tenant_info.add:
|
||||
tenant.add = tenant_info.add
|
||||
if tenant_info.domain_set:
|
||||
tenant.domain_set = tenant_info.domain_set
|
||||
if tenant_info.domain:
|
||||
tenant.domain = tenant_info.domain
|
||||
|
||||
tenant.is_initialized = True
|
||||
tenant.initialized_at = datetime.now()
|
||||
tenant.initialized_by = session.executed_by
|
||||
self.db.commit()
|
||||
results['tenant_updated'] = True
|
||||
|
||||
# Step 2: 建立「初始化部門」(每個租戶必備)
|
||||
init_dept_exists = self.db.query(Department).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
code='INIT'
|
||||
).first()
|
||||
|
||||
init_dept = None
|
||||
if not init_dept_exists:
|
||||
# 決定初始化部門的 email_domain
|
||||
# domain_set=1 (組織網域): 使用 tenant.domain
|
||||
# domain_set=2 (部門網域): 使用 tenant_info.domain 作為初始化部門的網域
|
||||
if tenant_info.domain_set == 1:
|
||||
# 組織網域模式: 必須設定 domain
|
||||
if not tenant_info.domain:
|
||||
raise ValueError("組織網域模式必須設定網域")
|
||||
init_dept_domain = tenant_info.domain
|
||||
else:
|
||||
# 部門網域模式: domain 是初始化部門的預設網域
|
||||
if not tenant_info.domain:
|
||||
raise ValueError("請設定初始化部門的預設網域")
|
||||
init_dept_domain = tenant_info.domain
|
||||
|
||||
init_dept = Department(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1, # 第一個部門序號為 1
|
||||
code='INIT',
|
||||
name='初始化部門',
|
||||
name_en='Initialization Department',
|
||||
email_domain=init_dept_domain,
|
||||
depth=0,
|
||||
description='系統初始化專用部門,待組織架構建立完成後可刪除或保留',
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(init_dept)
|
||||
self.db.commit()
|
||||
self.db.refresh(init_dept)
|
||||
results['departments_created'] += 1
|
||||
|
||||
# Step 2.1: 建立用戶自訂的其他部門 (如果有)
|
||||
dept_setups = self.db.query(InstallationDepartmentSetup).filter_by(
|
||||
session_id=session_id,
|
||||
is_created=False
|
||||
).all()
|
||||
|
||||
for dept_setup in dept_setups:
|
||||
dept = Department(
|
||||
tenant_id=session.tenant_id,
|
||||
code=dept_setup.department_code,
|
||||
name=dept_setup.department_name,
|
||||
name_en=dept_setup.department_name_en,
|
||||
email_domain=dept_setup.email_domain,
|
||||
depth=dept_setup.depth,
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(dept)
|
||||
dept_setup.is_created = True
|
||||
results['departments_created'] += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# Step 3: 建立系統管理員員工 (歸屬於初始化部門)
|
||||
# 取得初始化部門 ID
|
||||
if not init_dept:
|
||||
init_dept = self.db.query(Department).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
code='INIT'
|
||||
).first()
|
||||
|
||||
if not init_dept:
|
||||
raise ValueError("找不到初始化部門,無法建立管理員")
|
||||
|
||||
# 決定 SSO 帳號名稱:使用英文名稱(第一個管理員不會有衝突)
|
||||
sso_username = tenant_info.admin_english_name
|
||||
if not sso_username:
|
||||
raise ValueError("管理員英文名稱為必填項")
|
||||
|
||||
# 檢查是否已存在(使用 tenant_emp_settings 複合主鍵)
|
||||
admin_exists = self.db.query(EmpSetting).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1 # 第一個員工
|
||||
).first()
|
||||
|
||||
if not admin_exists:
|
||||
# Step 3-1: 建立人員基本資料 (EmpResume)
|
||||
resume = EmpResume(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1, # 第一個員工序號為 1
|
||||
legal_name=tenant_info.admin_legal_name,
|
||||
english_name=tenant_info.admin_english_name,
|
||||
id_number=f"INIT{session.tenant_id}001", # 初始化用臨時身分證號
|
||||
mobile=tenant_info.admin_phone,
|
||||
personal_email=tenant_info.admin_email,
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(resume)
|
||||
self.db.commit()
|
||||
self.db.refresh(resume)
|
||||
results['resumes_created'] = 1
|
||||
|
||||
# Step 3-2: 建立員工任用設定 (EmpSetting) - 複合主鍵
|
||||
emp_setting = EmpSetting(
|
||||
tenant_id=session.tenant_id,
|
||||
seq_no=1, # 第一個員工(或由觸發器自動生成)
|
||||
tenant_resume_id=resume.id,
|
||||
tenant_emp_code=f"{tenant_info.tenant_prefix}0001", # 或由觸發器自動生成
|
||||
hire_at=datetime.now().date(),
|
||||
employment_type='full_time',
|
||||
employment_status='active',
|
||||
primary_dept_id=init_dept.id,
|
||||
storage_quota_gb=100, # 管理員預設 100GB
|
||||
email_quota_mb=10240, # 管理員預設 10GB
|
||||
tenant_keycloak_username=sso_username, # 優先使用英文名稱,有衝突時使用 admin_username
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(emp_setting)
|
||||
self.db.commit()
|
||||
self.db.refresh(emp_setting)
|
||||
results['emp_settings_created'] = 1
|
||||
|
||||
# 決定郵件網域:使用初始化部門的網域
|
||||
if not init_dept.email_domain:
|
||||
raise ValueError("初始化部門未設定郵件網域,請檢查設定")
|
||||
|
||||
# 管理員郵件地址:使用 SSO 帳號名稱 + 網域
|
||||
admin_email_address = f"{sso_username}@{init_dept.email_domain}"
|
||||
|
||||
# Step 4: 建立 Keycloak 用戶
|
||||
temp_password = self.db.query(TemporaryPassword).filter_by(
|
||||
session_id=session_id,
|
||||
is_used=False
|
||||
).first()
|
||||
|
||||
if temp_password and temp_password.plain_password:
|
||||
# 建立 Keycloak 用戶(同時設定臨時密碼)
|
||||
user_id = self.keycloak_service.create_user(
|
||||
username=sso_username, # 使用英文名稱作為 SSO 帳號
|
||||
email=admin_email_address,
|
||||
first_name=tenant_info.admin_legal_name.split()[0] if tenant_info.admin_legal_name else '',
|
||||
last_name=tenant_info.admin_legal_name.split()[-1] if len(tenant_info.admin_legal_name.split()) > 1 else '',
|
||||
enabled=True,
|
||||
temporary_password=temp_password.plain_password # ✅ 設定臨時密碼,強制首次登入修改
|
||||
)
|
||||
|
||||
# 檢查 Keycloak 用戶是否建立成功
|
||||
if not user_id:
|
||||
raise ValueError(f"Keycloak 用戶建立失敗: {sso_username}")
|
||||
|
||||
# 更新員工任用設定的 Keycloak User ID
|
||||
emp_setting.tenant_keycloak_user_id = user_id
|
||||
|
||||
# 標記臨時密碼已使用(但保留明文密碼供用戶記錄)
|
||||
temp_password.is_used = True
|
||||
temp_password.used_at = datetime.now()
|
||||
# ⚠️ 不立即清除明文密碼,保留給用戶記錄
|
||||
# temp_password.plain_password = None
|
||||
# temp_password.plain_password_cleared_at = datetime.now()
|
||||
# temp_password.cleared_reason = 'keycloak_created'
|
||||
|
||||
self.db.commit()
|
||||
results['keycloak_user_created'] = True
|
||||
|
||||
# Step 4.5: 建立郵件帳號 (Docker Mailserver)
|
||||
try:
|
||||
from app.services.mailserver_service import MailserverService
|
||||
from app.utils.password_generator import generate_secure_password
|
||||
|
||||
mailserver = MailserverService()
|
||||
|
||||
# 為管理員建立郵件帳號
|
||||
# 郵件地址已在 Step 3 決定: admin_email_address
|
||||
# 郵件密碼:如果臨時密碼還有明文則使用,否則自動生成新密碼
|
||||
mail_password = temp_password.plain_password if (temp_password and temp_password.plain_password) else generate_secure_password()
|
||||
|
||||
mail_result = mailserver.create_email_account(
|
||||
email=admin_email_address,
|
||||
password=mail_password,
|
||||
quota_mb=emp_setting.email_quota_mb
|
||||
)
|
||||
|
||||
if mail_result.get('success'):
|
||||
results['mailbox_created'] = True
|
||||
print(f"[OK] 郵件帳號建立成功: {admin_email_address}")
|
||||
else:
|
||||
# 郵件建立失敗僅記錄 warning,不中斷初始化流程
|
||||
print(f"[WARNING] 郵件帳號建立失敗: {mail_result.get('error', 'Unknown error')}")
|
||||
results['mailbox_created'] = False
|
||||
results['mailbox_error'] = mail_result.get('error')
|
||||
|
||||
except Exception as mail_error:
|
||||
# 郵件系統錯誤不應中斷初始化流程
|
||||
print(f"[WARNING] 郵件系統整合失敗: {str(mail_error)}")
|
||||
results['mailbox_created'] = False
|
||||
results['mailbox_error'] = str(mail_error)
|
||||
|
||||
# Step 5: 分配系統管理員角色
|
||||
sys_admin_role = self.db.query(UserRole).filter_by(
|
||||
tenant_id=session.tenant_id,
|
||||
role_code='SYS_ADMIN'
|
||||
).first()
|
||||
|
||||
if sys_admin_role:
|
||||
from app.models import UserRoleAssignment
|
||||
role_assignment = UserRoleAssignment(
|
||||
tenant_id=session.tenant_id,
|
||||
keycloak_user_id=emp_setting.tenant_keycloak_user_id,
|
||||
role_id=sys_admin_role.id,
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(role_assignment)
|
||||
self.db.commit()
|
||||
results['roles_assigned'] = True
|
||||
|
||||
# 標記初始化完成並自動鎖定
|
||||
session.status = 'completed'
|
||||
session.completed_at = datetime.now()
|
||||
session.completed_steps = 5
|
||||
session.is_locked = True
|
||||
session.locked_at = datetime.now()
|
||||
session.locked_by = 'system'
|
||||
session.lock_reason = '初始化完成自動鎖定'
|
||||
|
||||
tenant_info.is_completed = True
|
||||
tenant_info.completed_at = datetime.now()
|
||||
tenant_info.completed_by = session.executed_by
|
||||
|
||||
# 更新系統狀態:從 initialization → operational
|
||||
from app.models.installation import InstallationSystemStatus
|
||||
system_status = self.db.query(InstallationSystemStatus).filter_by(id=1).first()
|
||||
if system_status:
|
||||
system_status.previous_phase = system_status.current_phase
|
||||
system_status.current_phase = 'operational'
|
||||
system_status.phase_changed_at = datetime.now()
|
||||
system_status.phase_changed_by = session.executed_by or 'installer'
|
||||
system_status.phase_change_reason = '初始化完成,系統進入正式運作階段'
|
||||
system_status.initialization_completed = True
|
||||
system_status.initialized_at = datetime.now()
|
||||
system_status.initialized_by = session.executed_by or 'installer'
|
||||
system_status.operational_since = datetime.now()
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# 記錄鎖定日誌
|
||||
self._log_access(
|
||||
session_id=session_id,
|
||||
action='lock',
|
||||
action_by='system',
|
||||
action_method='auto',
|
||||
access_granted=True
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
session.status = 'failed'
|
||||
self.db.commit()
|
||||
raise Exception(f"初始化執行失敗: {str(e)}")
|
||||
|
||||
# ==================== 明文密碼管理 ====================
|
||||
|
||||
def clear_plain_password(
|
||||
self,
|
||||
session_id: int,
|
||||
reason: str = 'user_confirmed'
|
||||
) -> bool:
|
||||
"""
|
||||
清除臨時密碼的明文
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
reason: 清除原因
|
||||
|
||||
Returns:
|
||||
是否成功清除
|
||||
"""
|
||||
temp_passwords = self.db.query(TemporaryPassword).filter_by(
|
||||
session_id=session_id,
|
||||
is_used=False
|
||||
).filter(
|
||||
TemporaryPassword.plain_password.isnot(None)
|
||||
).all()
|
||||
|
||||
for temp_pwd in temp_passwords:
|
||||
temp_pwd.plain_password = None
|
||||
temp_pwd.plain_password_cleared_at = datetime.now()
|
||||
temp_pwd.cleared_reason = reason
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return len(temp_passwords) > 0
|
||||
|
||||
# ==================== 存取控制 ====================
|
||||
|
||||
def check_session_access(
|
||||
self,
|
||||
session_id: int,
|
||||
action: str,
|
||||
action_by: str
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
檢查是否可以存取安裝會話的敏感資訊
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
action: 動作 (view/download_pdf)
|
||||
action_by: 操作人
|
||||
|
||||
Returns:
|
||||
(是否允許, 拒絕原因)
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
return False, "安裝會話不存在"
|
||||
|
||||
# 檢查鎖定狀態
|
||||
if session.is_locked:
|
||||
# 檢查臨時解鎖是否有效
|
||||
if session.unlock_expires_at and session.unlock_expires_at > datetime.now():
|
||||
return True, None
|
||||
else:
|
||||
return False, "會話已鎖定"
|
||||
|
||||
return True, None
|
||||
|
||||
def _log_access(
|
||||
self,
|
||||
session_id: int,
|
||||
action: str,
|
||||
action_by: str,
|
||||
action_method: str,
|
||||
access_granted: bool,
|
||||
deny_reason: Optional[str] = None,
|
||||
sensitive_data_accessed: Optional[List[str]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> InstallationAccessLog:
|
||||
"""
|
||||
記錄存取日誌
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
action: 動作
|
||||
action_by: 操作人
|
||||
action_method: 操作方式
|
||||
access_granted: 是否允許
|
||||
deny_reason: 拒絕原因
|
||||
sensitive_data_accessed: 存取的敏感資料
|
||||
ip_address: IP 位址
|
||||
user_agent: User Agent
|
||||
|
||||
Returns:
|
||||
存取日誌物件
|
||||
"""
|
||||
log = InstallationAccessLog(
|
||||
session_id=session_id,
|
||||
action=action,
|
||||
action_by=action_by,
|
||||
action_method=action_method,
|
||||
access_granted=access_granted,
|
||||
deny_reason=deny_reason,
|
||||
sensitive_data_accessed=sensitive_data_accessed,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
self.db.add(log)
|
||||
self.db.commit()
|
||||
return log
|
||||
|
||||
# ==================== 查詢功能 ====================
|
||||
|
||||
def get_session_details(
|
||||
self,
|
||||
session_id: int,
|
||||
include_sensitive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取得安裝會話詳細資訊
|
||||
|
||||
Args:
|
||||
session_id: 安裝會話 ID
|
||||
include_sensitive: 是否包含敏感資訊(需檢查存取權限)
|
||||
|
||||
Returns:
|
||||
會話詳細資訊
|
||||
"""
|
||||
session = self.db.query(InstallationSession).get(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"找不到安裝會話 ID: {session_id}")
|
||||
|
||||
result = {
|
||||
"session": {
|
||||
"id": session.id,
|
||||
"session_name": session.session_name,
|
||||
"environment": session.environment,
|
||||
"status": session.status,
|
||||
"started_at": session.started_at,
|
||||
"completed_at": session.completed_at,
|
||||
"is_locked": session.is_locked,
|
||||
"locked_at": session.locked_at,
|
||||
"lock_reason": session.lock_reason,
|
||||
"unlock_expires_at": session.unlock_expires_at
|
||||
},
|
||||
"tenant_info": None,
|
||||
"departments": [],
|
||||
"credentials": None
|
||||
}
|
||||
|
||||
# 租戶資訊
|
||||
tenant_info = session.tenant_info
|
||||
if tenant_info:
|
||||
result["tenant_info"] = {
|
||||
"company_name": tenant_info.company_name,
|
||||
"company_name_en": tenant_info.company_name_en,
|
||||
"tax_id": tenant_info.tax_id,
|
||||
"admin_username": tenant_info.admin_username,
|
||||
"admin_email": tenant_info.admin_email,
|
||||
"admin_legal_name": tenant_info.admin_legal_name
|
||||
}
|
||||
|
||||
# 部門設定
|
||||
result["departments"] = [
|
||||
{
|
||||
"code": d.department_code,
|
||||
"name": d.department_name,
|
||||
"name_en": d.department_name_en,
|
||||
"email_domain": d.email_domain,
|
||||
"is_created": d.is_created
|
||||
}
|
||||
for d in session.department_setups
|
||||
]
|
||||
|
||||
# 敏感資訊(密碼)
|
||||
if include_sensitive and not session.is_locked:
|
||||
temp_password = self.db.query(TemporaryPassword).filter_by(
|
||||
session_id=session_id
|
||||
).first()
|
||||
|
||||
if temp_password:
|
||||
result["credentials"] = {
|
||||
"password_visible": temp_password.plain_password is not None,
|
||||
"plain_password": temp_password.plain_password,
|
||||
"password_hash": temp_password.password_hash,
|
||||
"created_at": temp_password.created_at,
|
||||
"expires_at": temp_password.expires_at,
|
||||
"view_count": temp_password.view_count,
|
||||
"cleared_at": temp_password.plain_password_cleared_at,
|
||||
"cleared_reason": temp_password.cleared_reason
|
||||
}
|
||||
|
||||
return result
|
||||
816
backend/app/services/keycloak_admin_client.py
Normal file
816
backend/app/services/keycloak_admin_client.py
Normal file
@@ -0,0 +1,816 @@
|
||||
"""
|
||||
Keycloak Admin REST API 客戶端
|
||||
直接使用 REST API,避免 python-keycloak 套件的版本兼容性問題
|
||||
"""
|
||||
import requests
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class KeycloakAdminClient:
|
||||
"""Keycloak Admin REST API 客戶端"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化客戶端"""
|
||||
self.server_url = settings.KEYCLOAK_URL
|
||||
self.realm = settings.KEYCLOAK_REALM
|
||||
self.admin_username = settings.KEYCLOAK_ADMIN_USERNAME
|
||||
self.admin_password = settings.KEYCLOAK_ADMIN_PASSWORD
|
||||
self._access_token: Optional[str] = None
|
||||
|
||||
def _get_admin_token(self) -> Optional[str]:
|
||||
"""
|
||||
獲取 Admin 訪問令牌
|
||||
|
||||
Returns:
|
||||
str: Access Token, 失敗返回 None
|
||||
"""
|
||||
try:
|
||||
token_url = f"{self.server_url}/realms/master/protocol/openid-connect/token"
|
||||
|
||||
data = {
|
||||
"client_id": "admin-cli",
|
||||
"username": self.admin_username,
|
||||
"password": self.admin_password,
|
||||
"grant_type": "password",
|
||||
}
|
||||
|
||||
response = requests.post(token_url, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data.get("access_token")
|
||||
return self._access_token
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get admin token: {e}")
|
||||
return None
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
獲取請求標頭
|
||||
|
||||
Returns:
|
||||
dict: 包含 Authorization 的標頭
|
||||
"""
|
||||
if not self._access_token:
|
||||
self._get_admin_token()
|
||||
|
||||
return {
|
||||
"Authorization": f"Bearer {self._access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def get_users(self, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
獲取用戶列表
|
||||
|
||||
Args:
|
||||
query: 查詢參數 (username, email, first, max, etc.)
|
||||
|
||||
Returns:
|
||||
list: 用戶列表
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users"
|
||||
params = query or {}
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# 如果是 401,重新獲取 token 並重試
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get users: {e}")
|
||||
return []
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根據用戶名獲取用戶
|
||||
|
||||
Args:
|
||||
username: 用戶名稱
|
||||
|
||||
Returns:
|
||||
dict: 用戶資料, 不存在返回 None
|
||||
"""
|
||||
users = self.get_users({"username": username, "exact": True})
|
||||
return users[0] if users else None
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根據 ID 獲取用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
dict: 用戶資料
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
enabled: bool = True,
|
||||
email_verified: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
創建用戶
|
||||
|
||||
Args:
|
||||
username: 用戶名稱
|
||||
email: 郵件地址
|
||||
first_name: 名字
|
||||
last_name: 姓氏
|
||||
enabled: 是否啟用
|
||||
email_verified: 郵件是否已驗證
|
||||
|
||||
Returns:
|
||||
str: User ID (成功時), None (失敗時)
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users"
|
||||
|
||||
user_data = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"enabled": enabled,
|
||||
"emailVerified": email_verified,
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# Keycloak 在 Location header 返回新用戶的 URL
|
||||
location = response.headers.get("Location", "")
|
||||
user_id = location.split("/")[-1] if location else None
|
||||
|
||||
print(f"✓ Created user: {username} (ID: {user_id})")
|
||||
return user_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create user {username}: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f" Response: {e.response.text}")
|
||||
return None
|
||||
|
||||
def update_user(self, user_id: str, user_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
更新用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
user_data: 要更新的資料
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}"
|
||||
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=user_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Updated user: {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to update user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def enable_user(self, user_id: str) -> bool:
|
||||
"""啟用用戶"""
|
||||
return self.update_user(user_id, {"enabled": True})
|
||||
|
||||
def disable_user(self, user_id: str) -> bool:
|
||||
"""停用用戶"""
|
||||
return self.update_user(user_id, {"enabled": False})
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
刪除用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}"
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Deleted user: {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to delete user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def reset_password(
|
||||
self,
|
||||
user_id: str,
|
||||
password: str,
|
||||
temporary: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
重設密碼
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
password: 新密碼
|
||||
temporary: 是否為臨時密碼
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{self.realm}/users/{user_id}/reset-password"
|
||||
|
||||
credential = {
|
||||
"type": "password",
|
||||
"value": password,
|
||||
"temporary": temporary,
|
||||
}
|
||||
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=credential,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=credential,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Reset password for user: {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to reset password for {user_id}: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Realm Management ====================
|
||||
|
||||
def create_realm(
|
||||
self,
|
||||
realm_name: str,
|
||||
display_name: str,
|
||||
enabled: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
建立新的 Keycloak Realm (僅限 Superuser)
|
||||
|
||||
Args:
|
||||
realm_name: Realm 識別碼 (例: porscheworld-pwd)
|
||||
display_name: 顯示名稱 (例: Porsche World)
|
||||
enabled: 是否啟用
|
||||
|
||||
Returns:
|
||||
dict: Realm 配置資訊, 失敗返回 None
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms"
|
||||
|
||||
realm_config = {
|
||||
"realm": realm_name,
|
||||
"displayName": display_name,
|
||||
"enabled": enabled,
|
||||
"sslRequired": "external",
|
||||
"registrationAllowed": False, # 不允許自助註冊
|
||||
"loginWithEmailAllowed": True,
|
||||
"duplicateEmailsAllowed": False,
|
||||
"resetPasswordAllowed": True,
|
||||
"editUsernameAllowed": False,
|
||||
"bruteForceProtected": True,
|
||||
"permanentLockout": False,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"failureFactor": 5,
|
||||
# Token 設定
|
||||
"accessTokenLifespan": 1800, # 30 分鐘
|
||||
"ssoSessionIdleTimeout": 3600, # 1 小時
|
||||
"ssoSessionMaxLifespan": 36000, # 10 小時
|
||||
"offlineSessionIdleTimeout": 2592000, # 30 天
|
||||
# 國際化設定
|
||||
"internationalizationEnabled": True,
|
||||
"supportedLocales": ["zh-TW", "en"],
|
||||
"defaultLocale": "zh-TW",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=realm_config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=realm_config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Created realm: {realm_name}")
|
||||
return realm_config
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create realm {realm_name}: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f" Response: {e.response.text}")
|
||||
return None
|
||||
|
||||
def get_realm(self, realm_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
取得 Realm 配置
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
|
||||
Returns:
|
||||
dict: Realm 配置資訊
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get realm {realm_name}: {e}")
|
||||
return None
|
||||
|
||||
def update_realm(self, realm_name: str, config: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
更新 Realm 配置
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
config: 要更新的配置
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}"
|
||||
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.put(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=config,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Updated realm: {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to update realm {realm_name}: {e}")
|
||||
return False
|
||||
|
||||
def delete_realm(self, realm_name: str) -> bool:
|
||||
"""
|
||||
刪除 Realm (危險操作,僅限 Superuser)
|
||||
|
||||
⚠️ WARNING: 此操作會刪除 Realm 中所有使用者、角色、客戶端等資料
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}"
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Deleted realm: {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to delete realm {realm_name}: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Realm Role Management ====================
|
||||
|
||||
def create_realm_role(
|
||||
self,
|
||||
realm_name: str,
|
||||
role_name: str,
|
||||
description: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
在指定 Realm 建立角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
role_name: 角色名稱
|
||||
description: 角色說明
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles"
|
||||
|
||||
role_data = {
|
||||
"name": role_name,
|
||||
"description": description or f"Role: {role_name}",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Created realm role: {role_name} in {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create realm role {role_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_realm_roles(self, realm_name: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
取得 Realm 所有角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
|
||||
Returns:
|
||||
list: 角色列表
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get realm roles for {realm_name}: {e}")
|
||||
return []
|
||||
|
||||
def delete_realm_role(self, realm_name: str, role_name: str) -> bool:
|
||||
"""
|
||||
刪除 Realm 角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles/{role_name}"
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Deleted realm role: {role_name} from {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to delete realm role {role_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_realm_role_by_name(self, realm_name: str, role_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
取得指定 Realm 角色的詳細資訊
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
dict: 角色資訊 (包含 id, name, description 等)
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/roles/{role_name}"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get realm role {role_name}: {e}")
|
||||
return None
|
||||
|
||||
def assign_realm_role_to_user(
|
||||
self,
|
||||
realm_name: str,
|
||||
user_id: str,
|
||||
role_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
將 Realm 角色分配給使用者
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
user_id: Keycloak User ID
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
# Step 1: 取得角色詳細資訊 (需要 role id)
|
||||
role = self.get_realm_role_by_name(realm_name, role_name)
|
||||
if not role:
|
||||
print(f"✗ Role {role_name} not found in realm {realm_name}")
|
||||
return False
|
||||
|
||||
# Step 2: 分配角色給使用者
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm"
|
||||
|
||||
# Keycloak 要求傳入角色的完整資訊 (id, name 等)
|
||||
role_mapping = [{
|
||||
"id": role["id"],
|
||||
"name": role["name"],
|
||||
}]
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Assigned role '{role_name}' to user {user_id} in realm {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to assign role {role_name} to user {user_id}: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f" Response: {e.response.text}")
|
||||
return False
|
||||
|
||||
def remove_realm_role_from_user(
|
||||
self,
|
||||
realm_name: str,
|
||||
user_id: str,
|
||||
role_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
從使用者移除 Realm 角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
user_id: Keycloak User ID
|
||||
role_name: 角色名稱
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
try:
|
||||
# Step 1: 取得角色詳細資訊
|
||||
role = self.get_realm_role_by_name(realm_name, role_name)
|
||||
if not role:
|
||||
print(f"✗ Role {role_name} not found in realm {realm_name}")
|
||||
return False
|
||||
|
||||
# Step 2: 從使用者移除角色
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm"
|
||||
|
||||
role_mapping = [{
|
||||
"id": role["id"],
|
||||
"name": role["name"],
|
||||
}]
|
||||
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.delete(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=role_mapping,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print(f"✓ Removed role '{role_name}' from user {user_id} in realm {realm_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to remove role {role_name} from user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_user_realm_roles(self, realm_name: str, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
取得使用者的所有 Realm 角色
|
||||
|
||||
Args:
|
||||
realm_name: Realm 名稱
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
list: 角色列表
|
||||
"""
|
||||
try:
|
||||
url = f"{self.server_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm"
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
self._get_admin_token()
|
||||
response = requests.get(url, headers=self._get_headers(), timeout=10)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get user roles for {user_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# 全域實例 (延遲初始化)
|
||||
_keycloak_admin_client: Optional[KeycloakAdminClient] = None
|
||||
|
||||
|
||||
def get_keycloak_admin_client() -> KeycloakAdminClient:
|
||||
"""獲取 Keycloak Admin 客戶端實例 (單例)"""
|
||||
global _keycloak_admin_client
|
||||
if _keycloak_admin_client is None:
|
||||
_keycloak_admin_client = KeycloakAdminClient()
|
||||
return _keycloak_admin_client
|
||||
332
backend/app/services/keycloak_service.py
Normal file
332
backend/app/services/keycloak_service.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Keycloak SSO 整合服務
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from keycloak import KeycloakAdmin, KeycloakOpenID
|
||||
from keycloak.exceptions import KeycloakError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class KeycloakService:
|
||||
"""Keycloak 服務類別"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 Keycloak 連線"""
|
||||
self.server_url = settings.KEYCLOAK_URL
|
||||
self.realm_name = settings.KEYCLOAK_REALM
|
||||
self.client_id = settings.KEYCLOAK_CLIENT_ID
|
||||
self.client_secret = settings.KEYCLOAK_CLIENT_SECRET
|
||||
self._admin = None
|
||||
self._openid = None
|
||||
|
||||
@property
|
||||
def admin(self) -> Optional[KeycloakAdmin]:
|
||||
"""延遲初始化 Keycloak Admin 客戶端"""
|
||||
if self._admin is None and settings.KEYCLOAK_ADMIN_USERNAME and settings.KEYCLOAK_ADMIN_PASSWORD:
|
||||
try:
|
||||
# Keycloak 26.x 需要完整的 server_url (不含 /auth)
|
||||
self._admin = KeycloakAdmin(
|
||||
server_url=self.server_url,
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=self.realm_name,
|
||||
user_realm_name="master", # Admin 登入的 realm (通常是 master)
|
||||
verify=True,
|
||||
timeout=10 # 設定 10 秒超時
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to initialize Keycloak Admin: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return self._admin
|
||||
|
||||
@property
|
||||
def openid(self) -> KeycloakOpenID:
|
||||
"""延遲初始化 Keycloak OpenID 客戶端"""
|
||||
if self._openid is None:
|
||||
self._openid = KeycloakOpenID(
|
||||
server_url=self.server_url,
|
||||
client_id=self.client_id,
|
||||
realm_name=self.realm_name,
|
||||
client_secret_key=self.client_secret
|
||||
)
|
||||
return self._openid
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
enabled: bool = True,
|
||||
email_verified: bool = False,
|
||||
temporary_password: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
創建 Keycloak 用戶
|
||||
|
||||
Args:
|
||||
username: 用戶名稱 (username_base@email_domain)
|
||||
email: 郵件地址 (同 username)
|
||||
first_name: 名字
|
||||
last_name: 姓氏
|
||||
enabled: 是否啟用
|
||||
email_verified: 郵件是否已驗證
|
||||
temporary_password: 臨時密碼 (用戶首次登入需修改)
|
||||
|
||||
Returns:
|
||||
str: Keycloak User ID (UUID), 失敗返回 None
|
||||
"""
|
||||
if not self.admin:
|
||||
print("Error: Keycloak Admin not initialized")
|
||||
return None
|
||||
|
||||
try:
|
||||
# 創建用戶
|
||||
user_data = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"enabled": enabled,
|
||||
"emailVerified": email_verified,
|
||||
}
|
||||
|
||||
# 如果提供臨時密碼
|
||||
if temporary_password:
|
||||
user_data["credentials"] = [{
|
||||
"type": "password",
|
||||
"value": temporary_password,
|
||||
"temporary": True # 用戶首次登入需修改
|
||||
}]
|
||||
|
||||
user_id = self.admin.create_user(user_data)
|
||||
print(f"[OK] Keycloak user created: {username} (ID: {user_id})")
|
||||
return user_id
|
||||
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to create Keycloak user {username}: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根據用戶名獲取用戶資訊
|
||||
|
||||
Args:
|
||||
username: 用戶名稱
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊, 不存在返回 None
|
||||
"""
|
||||
if not self.admin:
|
||||
return None
|
||||
|
||||
try:
|
||||
users = self.admin.get_users({"username": username})
|
||||
if users:
|
||||
return users[0]
|
||||
return None
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to get user {username}: {e}")
|
||||
return None
|
||||
|
||||
def update_user(self, user_id: str, user_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
更新用戶資訊
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
user_data: 要更新的用戶資料
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
if not self.admin:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.admin.update_user(user_id, user_data)
|
||||
print(f"[OK] Keycloak user updated: {user_id}")
|
||||
return True
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to update user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def disable_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
停用用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
return self.update_user(user_id, {"enabled": False})
|
||||
|
||||
def enable_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
啟用用戶
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
return self.update_user(user_id, {"enabled": True})
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
"""
|
||||
刪除用戶
|
||||
|
||||
注意: 這是實際刪除,建議使用 disable_user 進行軟刪除
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
if not self.admin:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.admin.delete_user(user_id)
|
||||
print(f"[OK] Keycloak user deleted: {user_id}")
|
||||
return True
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to delete user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def reset_password(
|
||||
self,
|
||||
user_id: str,
|
||||
new_password: str,
|
||||
temporary: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
重設用戶密碼
|
||||
|
||||
Args:
|
||||
user_id: Keycloak User ID
|
||||
new_password: 新密碼
|
||||
temporary: 是否為臨時密碼 (用戶首次登入需修改)
|
||||
|
||||
Returns:
|
||||
bool: 成功返回 True
|
||||
"""
|
||||
if not self.admin:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.admin.set_user_password(
|
||||
user_id,
|
||||
new_password,
|
||||
temporary=temporary
|
||||
)
|
||||
print(f"[OK] Password reset for user: {user_id}")
|
||||
return True
|
||||
except KeycloakError as e:
|
||||
print(f"[ERROR] Failed to reset password for {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
驗證 JWT Token
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
dict: Token payload (包含用戶資訊), 無效返回 None
|
||||
"""
|
||||
try:
|
||||
# python-keycloak 會自動從 Keycloak 獲取公鑰並驗證
|
||||
token_info = self.openid.decode_token(
|
||||
token,
|
||||
validate=True # 驗證簽名和過期時間
|
||||
)
|
||||
|
||||
return token_info
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
def get_user_info_from_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
從 Token 獲取用戶資訊
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊
|
||||
"""
|
||||
token_info = self.verify_token(token)
|
||||
if not token_info:
|
||||
return None
|
||||
|
||||
return {
|
||||
"username": token_info.get("preferred_username"),
|
||||
"email": token_info.get("email"),
|
||||
"first_name": token_info.get("given_name"),
|
||||
"last_name": token_info.get("family_name"),
|
||||
"sub": token_info.get("sub"), # Keycloak User ID
|
||||
"iss": token_info.get("iss"), # Issuer (用於多租戶)
|
||||
"realm_access": token_info.get("realm_access"), # 角色資訊
|
||||
}
|
||||
|
||||
def introspect_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
檢查 Token 狀態
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
dict: Token 資訊 (包含 active 狀態)
|
||||
"""
|
||||
try:
|
||||
return self.openid.introspect(token)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Token introspection failed: {e}")
|
||||
return None
|
||||
|
||||
def is_token_active(self, token: str) -> bool:
|
||||
"""
|
||||
檢查 Token 是否有效
|
||||
|
||||
Args:
|
||||
token: JWT Token
|
||||
|
||||
Returns:
|
||||
bool: 有效返回 True
|
||||
"""
|
||||
introspection = self.introspect_token(token)
|
||||
if not introspection:
|
||||
return False
|
||||
return introspection.get("active", False)
|
||||
|
||||
|
||||
# 全域 Keycloak 服務實例
|
||||
# keycloak_service = KeycloakService()
|
||||
|
||||
# 延遲初始化服務實例
|
||||
_keycloak_service_instance: Optional[KeycloakService] = None
|
||||
|
||||
def get_keycloak_service() -> KeycloakService:
|
||||
"""獲取 Keycloak 服務實例 (單例)"""
|
||||
global _keycloak_service_instance
|
||||
if _keycloak_service_instance is None:
|
||||
_keycloak_service_instance = KeycloakService()
|
||||
return _keycloak_service_instance
|
||||
|
||||
# 模擬屬性訪問
|
||||
class _KeycloakServiceProxy:
|
||||
def __getattr__(self, name):
|
||||
return getattr(get_keycloak_service(), name)
|
||||
|
||||
keycloak_service = _KeycloakServiceProxy()
|
||||
245
backend/app/services/mailserver_service.py
Normal file
245
backend/app/services/mailserver_service.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Docker Mailserver Service
|
||||
透過 SSH + docker exec 管理 Docker Mailserver 郵件帳號
|
||||
|
||||
部署架構:
|
||||
HR Portal (10.1.0.245 或正式環境) --SSH--> Ubuntu Server (10.1.0.254)
|
||||
Ubuntu Server --> docker exec mailserver setup ...
|
||||
|
||||
整合方式:
|
||||
paramiko SSH → docker exec mailserver setup email add/del/quota set
|
||||
|
||||
失敗處理原則:
|
||||
- SSH 連線失敗以 warning 記錄,回傳包含 error 的結果字典
|
||||
- 不拋出例外,不影響 Keycloak 等其他 onboarding 流程
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 郵件配額設定 (MB),依職級對應
|
||||
MAIL_QUOTA_BY_JOB_LEVEL = {
|
||||
"Junior": 2048, # 2 GB
|
||||
"Mid": 3072, # 3 GB
|
||||
"Senior": 5120, # 5 GB
|
||||
"Manager": 10240, # 10 GB
|
||||
}
|
||||
|
||||
|
||||
def get_mail_quota_by_job_level(job_level: str) -> int:
|
||||
"""根據職級取得郵件配額 (MB)"""
|
||||
return MAIL_QUOTA_BY_JOB_LEVEL.get(job_level, MAIL_QUOTA_BY_JOB_LEVEL["Junior"])
|
||||
|
||||
|
||||
class MailserverService:
|
||||
"""
|
||||
Docker Mailserver 管理 Service
|
||||
|
||||
透過 SSH 連線到 Ubuntu Server (10.1.0.254),
|
||||
再執行 docker exec mailserver setup 指令管理郵件帳號。
|
||||
|
||||
支援操作:
|
||||
- 建立郵件帳號
|
||||
- 設定配額
|
||||
- 停用帳號 (停止收信,保留資料)
|
||||
- 設定轉寄
|
||||
- 查詢帳號狀態
|
||||
"""
|
||||
|
||||
def __init__(self, ssh_host: str, ssh_port: int, ssh_user: str, ssh_password: str,
|
||||
container_name: str = "mailserver", timeout: int = 30):
|
||||
self.ssh_host = ssh_host
|
||||
self.ssh_port = ssh_port
|
||||
self.ssh_user = ssh_user
|
||||
self.ssh_password = ssh_password
|
||||
self.container_name = container_name
|
||||
self.timeout = timeout
|
||||
|
||||
def _exec_docker_command(self, *setup_args) -> tuple[bool, str, str]:
|
||||
"""
|
||||
透過 SSH 執行 docker exec mailserver setup 指令
|
||||
|
||||
Args:
|
||||
*setup_args: setup 子指令參數
|
||||
例如: "email", "add", "user@domain.com", "password"
|
||||
|
||||
Returns:
|
||||
(success: bool, stdout: str, stderr: str)
|
||||
"""
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
logger.error("缺少 paramiko 套件,請執行: pip install paramiko")
|
||||
return False, "", "缺少 paramiko 套件"
|
||||
|
||||
cmd = f"docker exec {self.container_name} setup " + " ".join(
|
||||
f'"{arg}"' if " " in str(arg) else str(arg) for arg in setup_args
|
||||
)
|
||||
logger.debug(f"執行 Mailserver 指令: docker exec {self.container_name} setup {' '.join(str(a) for a in setup_args[:2])} ...")
|
||||
|
||||
ssh = None
|
||||
try:
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(
|
||||
hostname=self.ssh_host,
|
||||
port=self.ssh_port,
|
||||
username=self.ssh_user,
|
||||
password=self.ssh_password,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=self.timeout)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
rc = stdout.channel.recv_exit_status()
|
||||
|
||||
success = (rc == 0)
|
||||
if not success:
|
||||
logger.warning(f"Mailserver 指令失敗 (rc={rc}): {err or out}")
|
||||
return success, out, err
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"SSH 連線到 {self.ssh_host} 失敗: {e}")
|
||||
return False, "", str(e)
|
||||
finally:
|
||||
if ssh:
|
||||
try:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_email_account(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
quota_mb: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
建立郵件帳號
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup email add <email> <password>
|
||||
docker exec mailserver setup quota set <email> <quota>M
|
||||
|
||||
Args:
|
||||
email: 郵件地址 (例: user@porscheworld.tw)
|
||||
password: 初始密碼 (建議後續透過 Keycloak SSO 管理)
|
||||
quota_mb: 配額 (MB)
|
||||
|
||||
Returns:
|
||||
{"created": bool, "email": str, "quota_mb": int, "message": str, "error": str|None}
|
||||
"""
|
||||
# 1. 建立帳號
|
||||
success, out, err = self._exec_docker_command(
|
||||
"email", "add", email, password
|
||||
)
|
||||
if not success:
|
||||
return {
|
||||
"created": False,
|
||||
"email": email,
|
||||
"quota_mb": quota_mb,
|
||||
"message": "建立郵件帳號失敗",
|
||||
"error": err or out,
|
||||
}
|
||||
|
||||
logger.info(f"Mailserver: 郵件帳號建立成功 {email}")
|
||||
|
||||
# 2. 設定配額
|
||||
self.set_quota(email, quota_mb)
|
||||
|
||||
return {
|
||||
"created": True,
|
||||
"email": email,
|
||||
"quota_mb": quota_mb,
|
||||
"message": f"郵件帳號建立成功 ({quota_mb}MB)",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
def set_quota(self, email: str, quota_mb: int) -> Dict[str, Any]:
|
||||
"""
|
||||
設定郵件配額
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup quota set <email> <quota>M
|
||||
|
||||
Returns:
|
||||
{"updated": bool, "email": str, "quota_mb": int, "error": str|None}
|
||||
"""
|
||||
success, out, err = self._exec_docker_command(
|
||||
"quota", "set", email, f"{quota_mb}M"
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Mailserver: 配額設定成功 {email} → {quota_mb}MB")
|
||||
else:
|
||||
logger.warning(f"Mailserver: 配額設定失敗 {email}: {err}")
|
||||
|
||||
return {
|
||||
"updated": success,
|
||||
"email": email,
|
||||
"quota_mb": quota_mb,
|
||||
"error": None if success else (err or out),
|
||||
}
|
||||
|
||||
def delete_email_account(self, email: str) -> Dict[str, Any]:
|
||||
"""
|
||||
刪除郵件帳號
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup email del <email>
|
||||
|
||||
Returns:
|
||||
{"deleted": bool, "email": str, "error": str|None}
|
||||
"""
|
||||
success, out, err = self._exec_docker_command(
|
||||
"email", "del", email
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Mailserver: 郵件帳號刪除成功 {email}")
|
||||
else:
|
||||
logger.warning(f"Mailserver: 郵件帳號刪除失敗 {email}: {err}")
|
||||
|
||||
return {
|
||||
"deleted": success,
|
||||
"email": email,
|
||||
"error": None if success else (err or out),
|
||||
}
|
||||
|
||||
def list_accounts(self) -> Dict[str, Any]:
|
||||
"""
|
||||
列出所有郵件帳號
|
||||
|
||||
執行:
|
||||
docker exec mailserver setup email list
|
||||
|
||||
Returns:
|
||||
{"accounts": list[str], "error": str|None}
|
||||
"""
|
||||
success, out, err = self._exec_docker_command("email", "list")
|
||||
if success:
|
||||
accounts = [line.strip() for line in out.splitlines() if line.strip()]
|
||||
return {"accounts": accounts, "error": None}
|
||||
return {"accounts": [], "error": err or out}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 延遲初始化單例
|
||||
# ============================================================
|
||||
_mailserver_service: Optional[MailserverService] = None
|
||||
|
||||
|
||||
def get_mailserver_service() -> MailserverService:
|
||||
"""取得 MailserverService 單例 (延遲初始化)"""
|
||||
global _mailserver_service
|
||||
if _mailserver_service is None:
|
||||
from app.core.config import settings
|
||||
_mailserver_service = MailserverService(
|
||||
ssh_host=settings.MAILSERVER_SSH_HOST,
|
||||
ssh_port=settings.MAILSERVER_SSH_PORT,
|
||||
ssh_user=settings.MAILSERVER_SSH_USER,
|
||||
ssh_password=settings.MAILSERVER_SSH_PASSWORD,
|
||||
container_name=settings.MAILSERVER_CONTAINER_NAME,
|
||||
timeout=settings.MAILSERVER_SSH_TIMEOUT,
|
||||
)
|
||||
return _mailserver_service
|
||||
211
backend/app/utils/password_generator.py
Normal file
211
backend/app/utils/password_generator.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
密碼產生與驗證工具
|
||||
"""
|
||||
import secrets
|
||||
import string
|
||||
import re
|
||||
import bcrypt
|
||||
|
||||
|
||||
def generate_secure_password(length: int = 16) -> str:
|
||||
"""
|
||||
產生安全的隨機密碼
|
||||
|
||||
Args:
|
||||
length: 密碼長度(預設 16 字元)
|
||||
|
||||
Returns:
|
||||
安全的隨機密碼
|
||||
|
||||
範例:
|
||||
>>> pwd = generate_secure_password()
|
||||
>>> len(pwd)
|
||||
16
|
||||
>>> validate_password_strength(pwd)
|
||||
True
|
||||
"""
|
||||
if length < 8:
|
||||
raise ValueError("密碼長度至少需要 8 個字元")
|
||||
|
||||
# 字元集合
|
||||
lowercase = string.ascii_lowercase
|
||||
uppercase = string.ascii_uppercase
|
||||
digits = string.digits
|
||||
special = "!@#$%^&*()-_=+[]{}|;:,.<>?"
|
||||
|
||||
# 確保至少包含每種類型各一個
|
||||
password = [
|
||||
secrets.choice(lowercase),
|
||||
secrets.choice(uppercase),
|
||||
secrets.choice(digits),
|
||||
secrets.choice(special)
|
||||
]
|
||||
|
||||
# 剩餘字元隨機選擇
|
||||
all_chars = lowercase + uppercase + digits + special
|
||||
password += [secrets.choice(all_chars) for _ in range(length - 4)]
|
||||
|
||||
# 打亂順序
|
||||
secrets.SystemRandom().shuffle(password)
|
||||
|
||||
return ''.join(password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
使用 bcrypt 加密密碼
|
||||
|
||||
Args:
|
||||
password: 明文密碼
|
||||
|
||||
Returns:
|
||||
加密後的密碼 hash
|
||||
"""
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
驗證密碼
|
||||
|
||||
Args:
|
||||
plain_password: 明文密碼
|
||||
hashed_password: 加密密碼
|
||||
|
||||
Returns:
|
||||
是否匹配
|
||||
"""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
驗證密碼強度
|
||||
|
||||
Args:
|
||||
password: 待驗證的密碼
|
||||
|
||||
Returns:
|
||||
(是否通過, 錯誤訊息列表)
|
||||
|
||||
範例:
|
||||
>>> validate_password_strength("weak")
|
||||
(False, ['密碼長度至少需要 8 個字元', ...])
|
||||
>>> validate_password_strength("Strong@Pass123")
|
||||
(True, [])
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# 長度檢查
|
||||
if len(password) < 8:
|
||||
errors.append("密碼長度至少需要 8 個字元")
|
||||
|
||||
# 大寫字母
|
||||
if not re.search(r'[A-Z]', password):
|
||||
errors.append("密碼必須包含至少一個大寫字母")
|
||||
|
||||
# 小寫字母
|
||||
if not re.search(r'[a-z]', password):
|
||||
errors.append("密碼必須包含至少一個小寫字母")
|
||||
|
||||
# 數字
|
||||
if not re.search(r'\d', password):
|
||||
errors.append("密碼必須包含至少一個數字")
|
||||
|
||||
# 特殊符號
|
||||
if not re.search(r'[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]', password):
|
||||
errors.append("密碼必須包含至少一個特殊符號")
|
||||
|
||||
# 常見弱密碼檢查
|
||||
common_weak_passwords = [
|
||||
'password', 'password123', '12345678', 'qwerty',
|
||||
'admin123', 'letmein', 'welcome', 'monkey'
|
||||
]
|
||||
if password.lower() in common_weak_passwords:
|
||||
errors.append("此密碼過於常見,請使用更安全的密碼")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
def validate_password_for_user(
|
||||
password: str,
|
||||
username: str = None,
|
||||
name: str = None,
|
||||
email: str = None
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
驗證密碼(包含使用者資訊檢查)
|
||||
|
||||
Args:
|
||||
password: 待驗證的密碼
|
||||
username: 使用者帳號
|
||||
name: 使用者姓名
|
||||
email: Email
|
||||
|
||||
Returns:
|
||||
(是否通過, 錯誤訊息列表)
|
||||
"""
|
||||
# 先檢查基本強度
|
||||
is_valid, errors = validate_password_strength(password)
|
||||
|
||||
# 檢查是否包含使用者資訊
|
||||
password_lower = password.lower()
|
||||
|
||||
if username and username.lower() in password_lower:
|
||||
errors.append("密碼不可包含帳號名稱")
|
||||
|
||||
if name:
|
||||
name_parts = name.split()
|
||||
for part in name_parts:
|
||||
if len(part) >= 3 and part.lower() in password_lower:
|
||||
errors.append("密碼不可包含姓名")
|
||||
break
|
||||
|
||||
if email:
|
||||
email_user = email.split('@')[0]
|
||||
if len(email_user) >= 3 and email_user.lower() in password_lower:
|
||||
errors.append("密碼不可包含 Email 使用者名稱")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 測試密碼產生
|
||||
print("=== 密碼產生測試 ===")
|
||||
for i in range(5):
|
||||
pwd = generate_secure_password()
|
||||
is_valid, errors = validate_password_strength(pwd)
|
||||
print(f"密碼 {i+1}: {pwd} - 有效: {is_valid}")
|
||||
|
||||
# 測試密碼驗證
|
||||
print("\n=== 密碼驗證測試 ===")
|
||||
test_cases = [
|
||||
("weak", False),
|
||||
("WeakPass", False),
|
||||
("WeakPass123", False),
|
||||
("Strong@Pass123", True),
|
||||
("admin@Pass123", True)
|
||||
]
|
||||
|
||||
for password, expected in test_cases:
|
||||
is_valid, errors = validate_password_strength(password)
|
||||
status = "✓" if is_valid == expected else "✗"
|
||||
print(f"{status} {password}: {is_valid}")
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
|
||||
# 測試加密與驗證
|
||||
print("\n=== 密碼加密測試 ===")
|
||||
plain_pwd = "TestPassword@123"
|
||||
hashed = hash_password(plain_pwd)
|
||||
print(f"明文密碼: {plain_pwd}")
|
||||
print(f"加密密碼: {hashed}")
|
||||
print(f"驗證正確密碼: {verify_password(plain_pwd, hashed)}")
|
||||
print(f"驗證錯誤密碼: {verify_password('WrongPassword', hashed)}")
|
||||
Reference in New Issue
Block a user