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

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

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

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

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

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

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

View File

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