feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,445 @@
"""
郵件帳號管理 API
符合 WebMail 設計規範 - 員工只能使用 HR Portal 授權的郵件帳號
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_
from app.db.session import get_db
from app.models.employee import Employee
from app.models.email_account import EmailAccount
from app.schemas.email_account import (
EmailAccountCreate,
EmailAccountUpdate,
EmailAccountResponse,
EmailAccountListItem,
EmailAccountQuotaUpdate,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.schemas.response import SuccessResponse, MessageResponse
from app.api.deps import get_pagination_params
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
return 1
@router.get("/", response_model=PaginatedResponse)
def get_email_accounts(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
is_active: Optional[bool] = Query(None, description="狀態篩選"),
search: Optional[str] = Query(None, description="搜尋郵件地址"),
):
"""
獲取郵件帳號列表
支援:
- 分頁
- 員工篩選
- 狀態篩選
- 郵件地址搜尋
"""
tenant_id = get_current_tenant_id()
query = db.query(EmailAccount).filter(EmailAccount.tenant_id == tenant_id)
# 員工篩選
if employee_id:
query = query.filter(EmailAccount.employee_id == employee_id)
# 狀態篩選
if is_active is not None:
query = query.filter(EmailAccount.is_active == is_active)
# 搜尋
if search:
search_pattern = f"%{search}%"
query = query.filter(EmailAccount.email_address.ilike(search_pattern))
# 總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
email_accounts = (
query.options(joinedload(EmailAccount.employee))
.offset(offset)
.limit(pagination.page_size)
.all()
)
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
# 組裝回應資料
items = []
for account in email_accounts:
item = EmailAccountListItem.model_validate(account)
item.employee_name = account.employee.legal_name if account.employee else None
item.employee_number = account.employee.employee_id if account.employee else None
items.append(item)
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=items,
)
@router.get("/{email_account_id}", response_model=EmailAccountResponse)
def get_email_account(
email_account_id: int,
db: Session = Depends(get_db),
):
"""
獲取郵件帳號詳情
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.post("/", response_model=EmailAccountResponse, status_code=status.HTTP_201_CREATED)
def create_email_account(
account_data: EmailAccountCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
創建郵件帳號
注意:
- 郵件地址必須唯一
- 員工必須存在
- 配額範圍: 1GB - 100GB
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == account_data.employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {account_data.employee_id} not found",
)
# 檢查郵件地址是否已存在
existing = db.query(EmailAccount).filter(
EmailAccount.email_address == account_data.email_address
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{account_data.email_address}' already exists",
)
# 創建郵件帳號
account = EmailAccount(
tenant_id=tenant_id,
employee_id=account_data.employee_id,
email_address=account_data.email_address,
quota_mb=account_data.quota_mb,
forward_to=account_data.forward_to,
auto_reply=account_data.auto_reply,
is_active=account_data.is_active,
)
db.add(account)
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="create_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"employee_id": account.employee_id,
"quota_mb": account.quota_mb,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
return response
@router.put("/{email_account_id}", response_model=EmailAccountResponse)
def update_email_account(
email_account_id: int,
account_data: EmailAccountUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新郵件帳號
可更新:
- 配額
- 轉寄地址
- 自動回覆
- 啟用狀態
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 記錄變更前的值
changes = {}
# 更新欄位
update_data = account_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
old_value = getattr(account, field)
if old_value != value:
changes[field] = {"from": old_value, "to": value}
setattr(account, field, value)
if changes:
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"changes": changes,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.patch("/{email_account_id}/quota", response_model=EmailAccountResponse)
def update_email_quota(
email_account_id: int,
quota_data: EmailAccountQuotaUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新郵件配額
快速更新配額的端點
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
old_quota = account.quota_mb
account.quota_mb = quota_data.quota_mb
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_email_quota",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"old_quota_mb": old_quota,
"new_quota_mb": quota_data.quota_mb,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.delete("/{email_account_id}", response_model=MessageResponse)
def delete_email_account(
email_account_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
刪除郵件帳號
注意:
- 軟刪除: 設為停用
- 需要記錄審計日誌
"""
tenant_id = get_current_tenant_id()
account = db.query(EmailAccount).filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
).first()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 軟刪除: 設為停用
account.is_active = False
db.commit()
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="delete_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"employee_id": account.employee_id,
},
)
return MessageResponse(
message=f"Email account {account.email_address} has been deactivated"
)
@router.get("/employees/{employee_id}/email-accounts")
def get_employee_email_accounts(
employee_id: int,
db: Session = Depends(get_db),
):
"""
取得員工授權的郵件帳號列表
符合 WebMail 設計規範 (HR Portal設計文件 §2):
- 員工不可自行新增郵件帳號
- 只能使用 HR Portal 授予的帳號
- 支援多帳號切換 (ISO 帳號管理流程)
回傳格式:
{
"user_id": "porsche.chen",
"email_accounts": [
{
"email": "porsche.chen@porscheworld.tw",
"quota_mb": 5120,
"status": "active",
...
}
]
}
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found",
)
# 查詢員工的所有啟用郵件帳號
accounts = (
db.query(EmailAccount)
.filter(
EmailAccount.employee_id == employee_id,
EmailAccount.tenant_id == tenant_id,
EmailAccount.is_active == True,
)
.all()
)
# 組裝符合 WebMail 設計規範的回應格式
email_accounts = []
for account in accounts:
email_accounts.append({
"email": account.email_address,
"quota_mb": account.quota_mb,
"status": "active" if account.is_active else "inactive",
"forward_to": account.forward_to,
"auto_reply": account.auto_reply,
})
return {
"user_id": employee.username_base,
"email_accounts": email_accounts,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
"""
批次作業模組
包含所有定時排程的批次處理任務
"""

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

View 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}")

View 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()

View 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}")

View File

136
backend/app/core/audit.py Normal file
View 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

View 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()

View 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()

View 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()

View 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()

View 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}")

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

View File

10
backend/app/db/base.py Normal file
View 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
View 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()

View 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
View 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")

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

View 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,
)

View 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}>"

View 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

View 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

View 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}>"

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

View 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}>"

View 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})>"

View 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})>"

View 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

View 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}"

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

View 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}"

View 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

View 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

View 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", # 唯讀
]

View 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
View 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}>"

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

View 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})>"

View 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})>"

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

View 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

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

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

View 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,
)

View 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"
}
}
)

View 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"
}
}
)

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

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

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

View 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
}
}
)

View 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

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

View 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
}
}
)

View 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

View 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="成功訊息")

View 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

View 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

View File

@@ -0,0 +1,10 @@
"""
Services 模組
匯出所有業務邏輯服務
"""
from app.services.audit_service import audit_service, AuditService
__all__ = [
"audit_service",
"AuditService",
]

View 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()

View 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

View 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

View 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 ''}")

View 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

View 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

View 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()

View 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

View 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)}")