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