diff --git a/backend/START_BACKEND.bat b/backend/START_BACKEND.bat
index d5a849a..826d62c 100644
--- a/backend/START_BACKEND.bat
+++ b/backend/START_BACKEND.bat
@@ -11,10 +11,10 @@ call venv\Scripts\activate.bat
echo.
echo [2/2] 啟動 FastAPI 服務...
-echo API Server: http://localhost:10181
-echo API Docs: http://localhost:10181/docs
+echo API Server: http://localhost:10281
+echo API Docs: http://localhost:10281/docs
echo.
-python -m uvicorn app.main:app --host 0.0.0.0 --port 10181 --reload
+python -m uvicorn app.main:app --host 0.0.0.0 --port 10281 --reload
pause
diff --git a/backend/alembic/versions/002_add_system_settings.py b/backend/alembic/versions/002_add_system_settings.py
new file mode 100644
index 0000000..671db56
--- /dev/null
+++ b/backend/alembic/versions/002_add_system_settings.py
@@ -0,0 +1,33 @@
+"""add system_settings table
+
+Revision ID: 002
+Revises: 001
+Create Date: 2026-03-14
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = '002'
+down_revision = '001'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'system_settings',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('site_title', sa.String(200), nullable=False, server_default='VMIS Admin Portal'),
+ sa.Column('version', sa.String(50), nullable=False, server_default='2.0.0'),
+ sa.Column('timezone', sa.String(100), nullable=False, server_default='Asia/Taipei'),
+ sa.Column('sso_enabled', sa.Boolean(), nullable=False, server_default='false'),
+ sa.Column('keycloak_url', sa.String(200), nullable=False, server_default='https://auth.lab.taipei'),
+ sa.Column('keycloak_realm', sa.String(100), nullable=False, server_default='vmis-admin'),
+ sa.Column('keycloak_client', sa.String(100), nullable=False, server_default='vmis-portal'),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
+ sa.PrimaryKeyConstraint('id'),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table('system_settings')
diff --git a/backend/alembic/versions/003_add_keycloak_admin_credentials.py b/backend/alembic/versions/003_add_keycloak_admin_credentials.py
new file mode 100644
index 0000000..3ab0d55
--- /dev/null
+++ b/backend/alembic/versions/003_add_keycloak_admin_credentials.py
@@ -0,0 +1,25 @@
+"""add keycloak admin credentials to system_settings
+
+Revision ID: 003
+Revises: 002
+Create Date: 2026-03-14
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = '003'
+down_revision = '002'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column('system_settings',
+ sa.Column('keycloak_admin_user', sa.String(100), nullable=False, server_default='admin'))
+ op.add_column('system_settings',
+ sa.Column('keycloak_admin_pass', sa.String(200), nullable=False, server_default=''))
+
+
+def downgrade() -> None:
+ op.drop_column('system_settings', 'keycloak_admin_pass')
+ op.drop_column('system_settings', 'keycloak_admin_user')
diff --git a/backend/alembic/versions/004_add_nc_mail_result.py b/backend/alembic/versions/004_add_nc_mail_result.py
new file mode 100644
index 0000000..3298140
--- /dev/null
+++ b/backend/alembic/versions/004_add_nc_mail_result.py
@@ -0,0 +1,23 @@
+"""add nc_mail_result to account_schedule_results
+
+Revision ID: 004
+Revises: 003
+Create Date: 2026-03-15
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "004"
+down_revision = "003"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column("account_schedule_results", sa.Column("nc_mail_result", sa.Boolean(), nullable=True))
+ op.add_column("account_schedule_results", sa.Column("nc_mail_done_at", sa.DateTime(), nullable=True))
+
+
+def downgrade():
+ op.drop_column("account_schedule_results", "nc_mail_done_at")
+ op.drop_column("account_schedule_results", "nc_mail_result")
diff --git a/backend/app/api/v1/accounts.py b/backend/app/api/v1/accounts.py
index 23af4ce..c2a4a0e 100644
--- a/backend/app/api/v1/accounts.py
+++ b/backend/app/api/v1/accounts.py
@@ -33,6 +33,7 @@ def _get_lights(db: Session, account_id: int) -> Optional[AccountStatusLight]:
sso_result=result.sso_result,
mailbox_result=result.mailbox_result,
nc_result=result.nc_result,
+ nc_mail_result=result.nc_mail_result,
quota_usage=result.quota_usage,
)
@@ -60,6 +61,7 @@ def list_accounts(
@router.post("", response_model=AccountResponse, status_code=201)
def create_account(payload: AccountCreate, db: Session = Depends(get_db)):
+ import secrets
tenant = db.get(Tenant, payload.tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
@@ -68,8 +70,12 @@ def create_account(payload: AccountCreate, db: Session = Depends(get_db)):
account_code = _build_account_code(tenant.prefix, seq_no)
email = f"{payload.sso_account}@{tenant.domain}"
+ data = payload.model_dump()
+ if not data.get("default_password"):
+ data["default_password"] = account_code # 預設密碼 = 帳號編碼,使用者首次登入後必須變更
+
account = Account(
- **payload.model_dump(),
+ **data,
seq_no=seq_no,
account_code=account_code,
email=email,
diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py
index 8c195c4..f0f01e3 100644
--- a/backend/app/api/v1/router.py
+++ b/backend/app/api/v1/router.py
@@ -1,5 +1,5 @@
from fastapi import APIRouter
-from app.api.v1 import tenants, accounts, schedules, servers, status
+from app.api.v1 import tenants, accounts, schedules, servers, status, settings
api_router = APIRouter()
api_router.include_router(tenants.router)
@@ -7,3 +7,4 @@ api_router.include_router(accounts.router)
api_router.include_router(schedules.router)
api_router.include_router(servers.router)
api_router.include_router(status.router)
+api_router.include_router(settings.router)
diff --git a/backend/app/api/v1/schedules.py b/backend/app/api/v1/schedules.py
index 6290e96..72d3630 100644
--- a/backend/app/api/v1/schedules.py
+++ b/backend/app/api/v1/schedules.py
@@ -1,12 +1,16 @@
from typing import List
from datetime import datetime
+from app.core.utils import now_tw
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from croniter import croniter
from app.core.database import get_db
from app.models.schedule import Schedule
-from app.schemas.schedule import ScheduleResponse, ScheduleUpdate, ScheduleLogResponse
+from app.schemas.schedule import (
+ ScheduleResponse, ScheduleUpdate, ScheduleLogResponse,
+ LogResultsResponse, TenantResultItem, AccountResultItem,
+)
router = APIRouter(prefix="/schedules", tags=["schedules"])
@@ -29,9 +33,9 @@ def update_schedule_cron(schedule_id: int, payload: ScheduleUpdate, db: Session
s = db.get(Schedule, schedule_id)
if not s:
raise HTTPException(status_code=404, detail="Schedule not found")
- # Validate cron expression
+ # Validate cron expression (5-field: 分 時 日 月 週)
try:
- cron = croniter(payload.cron_timer, datetime.utcnow())
+ cron = croniter(payload.cron_timer, now_tw())
next_run = cron.get_next(datetime)
except Exception:
raise HTTPException(status_code=422, detail="Invalid cron expression")
@@ -67,3 +71,59 @@ def get_schedule_logs(schedule_id: int, limit: int = 20, db: Session = Depends(g
.all()
)
return logs
+
+
+@router.get("/{schedule_id}/logs/{log_id}/results", response_model=LogResultsResponse)
+def get_log_results(schedule_id: int, log_id: int, db: Session = Depends(get_db)):
+ """取得某次排程執行的詳細逐項結果"""
+ from app.models.result import TenantScheduleResult, AccountScheduleResult
+ from app.models.tenant import Tenant
+ from app.models.account import Account
+
+ tenant_results = []
+ account_results = []
+
+ if schedule_id == 1:
+ rows = (
+ db.query(TenantScheduleResult)
+ .filter(TenantScheduleResult.schedule_log_id == log_id)
+ .all()
+ )
+ for r in rows:
+ tenant = db.get(Tenant, r.tenant_id)
+ tenant_results.append(TenantResultItem(
+ tenant_id=r.tenant_id,
+ tenant_name=tenant.name if tenant else None,
+ traefik_status=r.traefik_status,
+ sso_result=r.sso_result,
+ mailbox_result=r.mailbox_result,
+ nc_result=r.nc_result,
+ office_result=r.office_result,
+ quota_usage=r.quota_usage,
+ fail_reason=r.fail_reason,
+ ))
+
+ elif schedule_id == 2:
+ rows = (
+ db.query(AccountScheduleResult)
+ .filter(AccountScheduleResult.schedule_log_id == log_id)
+ .all()
+ )
+ for r in rows:
+ account_results.append(AccountResultItem(
+ account_id=r.account_id,
+ sso_account=r.sso_account,
+ sso_result=r.sso_result,
+ mailbox_result=r.mailbox_result,
+ nc_result=r.nc_result,
+ nc_mail_result=r.nc_mail_result,
+ quota_usage=r.quota_usage,
+ fail_reason=r.fail_reason,
+ ))
+
+ return LogResultsResponse(
+ log_id=log_id,
+ schedule_id=schedule_id,
+ tenant_results=tenant_results,
+ account_results=account_results,
+ )
diff --git a/backend/app/api/v1/servers.py b/backend/app/api/v1/servers.py
index 5b4c99c..a10e503 100644
--- a/backend/app/api/v1/servers.py
+++ b/backend/app/api/v1/servers.py
@@ -1,5 +1,6 @@
from typing import List, Optional
from datetime import datetime, timedelta
+from app.core.utils import now_tw
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, case
from sqlalchemy.orm import Session
@@ -12,7 +13,7 @@ router = APIRouter(prefix="/servers", tags=["servers"])
def _calc_availability(db: Session, server_id: int, days: int) -> Optional[float]:
- since = datetime.utcnow() - timedelta(days=days)
+ since = now_tw() - timedelta(days=days)
row = (
db.query(
func.count().label("total"),
diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py
new file mode 100644
index 0000000..1f0997d
--- /dev/null
+++ b/backend/app/api/v1/settings.py
@@ -0,0 +1,135 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.utils import configure_timezone
+from app.models.settings import SystemSettings
+from app.models.tenant import Tenant
+from app.models.account import Account
+from app.schemas.settings import SettingsUpdate, SettingsResponse
+from app.services.keycloak_client import KeycloakClient
+
+router = APIRouter(prefix="/settings", tags=["settings"])
+
+
+def _get_or_create(db: Session) -> SystemSettings:
+ s = db.query(SystemSettings).first()
+ if not s:
+ s = SystemSettings(id=1)
+ db.add(s)
+ db.commit()
+ db.refresh(s)
+ return s
+
+
+@router.get("", response_model=SettingsResponse)
+def get_settings(db: Session = Depends(get_db)):
+ return _get_or_create(db)
+
+
+@router.put("", response_model=SettingsResponse)
+def update_settings(payload: SettingsUpdate, db: Session = Depends(get_db)):
+ s = _get_or_create(db)
+
+ # 啟用 SSO 前置條件檢查
+ if payload.sso_enabled is True:
+ manager = (
+ db.query(Tenant)
+ .filter(Tenant.is_manager == True, Tenant.is_active == True)
+ .first()
+ )
+ if not manager:
+ raise HTTPException(
+ status_code=422,
+ detail="啟用 SSO 前必須先建立 is_manager=true 的管理租戶"
+ )
+ has_account = (
+ db.query(Account)
+ .filter(Account.tenant_id == manager.id, Account.is_active == True)
+ .first()
+ )
+ if not has_account:
+ raise HTTPException(
+ status_code=422,
+ detail="管理租戶必須至少有一個有效帳號才能啟用 SSO"
+ )
+
+ for field, value in payload.model_dump(exclude_none=True).items():
+ setattr(s, field, value)
+ db.commit()
+ db.refresh(s)
+ configure_timezone(s.timezone)
+ return s
+
+
+@router.post("/test-keycloak")
+def test_keycloak(db: Session = Depends(get_db)):
+ """測試 Keycloak master realm 管理帳密是否正確"""
+ s = _get_or_create(db)
+ if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
+ raise HTTPException(status_code=400, detail="請先設定 Keycloak URL 及管理帳密")
+
+ kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
+ try:
+ token = kc._get_admin_token()
+ if token:
+ return {"ok": True, "message": f"連線成功:{s.keycloak_url}"}
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Keycloak 連線失敗:{str(e)}")
+
+
+@router.post("/init-sso-realm")
+def init_sso_realm(db: Session = Depends(get_db)):
+ """
+ 建立 Admin Portal SSO 環境:
+ 1. 以管理租戶的 keycloak_realm 為準(無管理租戶時 fallback 至 system settings)
+ 2. 確認該 realm 存在(不存在則建立)
+ 3. 在該 realm 建立 vmis-portal Public Client
+ 4. 同步回寫 system_settings.keycloak_realm(前端 JS Adapter 使用)
+ """
+ s = _get_or_create(db)
+ if not s.keycloak_url or not s.keycloak_admin_user or not s.keycloak_admin_pass:
+ raise HTTPException(status_code=400, detail="請先設定並儲存 Keycloak 連線資訊")
+
+ # 以管理租戶的 keycloak_realm 為主要來源
+ manager = (
+ db.query(Tenant)
+ .filter(Tenant.is_manager == True, Tenant.is_active == True)
+ .first()
+ )
+ if manager and manager.keycloak_realm:
+ realm = manager.keycloak_realm
+ else:
+ realm = s.keycloak_realm or "vmis-admin"
+
+ client_id = s.keycloak_client or "vmis-portal"
+
+ kc = KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
+ results = []
+
+ # 確認/建立 realm
+ if kc.realm_exists(realm):
+ results.append(f"✓ Realm '{realm}' 已存在")
+ else:
+ ok = kc.create_realm(realm, manager.name if manager else "VMIS Admin Portal")
+ if ok:
+ results.append(f"✓ Realm '{realm}' 建立成功")
+ else:
+ raise HTTPException(status_code=500, detail=f"Realm '{realm}' 建立失敗")
+
+ # 建立 vmis-portal Public Client
+ status = kc.create_public_client(realm, client_id)
+ if status == "exists":
+ results.append(f"✓ Client '{client_id}' 已存在")
+ elif status == "created":
+ results.append(f"✓ Client '{client_id}' 建立成功")
+ else:
+ results.append(f"✗ Client '{client_id}' 建立失敗")
+
+ # 同步回寫 system_settings.keycloak_realm(前端 Keycloak JS Adapter 使用)
+ if s.keycloak_realm != realm:
+ s.keycloak_realm = realm
+ db.commit()
+ results.append(f"✓ 系統設定 keycloak_realm 同步為 '{realm}'")
+
+ return {"ok": True, "realm": realm, "details": results}
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 66b4fd1..dea428a 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -17,6 +17,11 @@ class Settings(BaseSettings):
DOCKER_SSH_USER: str = "porsche"
TENANT_DEPLOY_BASE: str = "/home/porsche/tenants"
+ NC_ADMIN_USER: str = "admin"
+ NC_ADMIN_PASSWORD: str = "NC1qaz2wsx"
+
+ OO_JWT_SECRET: str = "OnlyOffice2026Secret"
+
APP_ENV: str = "development"
APP_PORT: int = 10281
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 689bfa2..2d2ddd4 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -8,6 +8,7 @@ engine = create_engine(
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
+ connect_args={"options": "-c timezone=Asia/Taipei"},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
diff --git a/backend/app/core/utils.py b/backend/app/core/utils.py
new file mode 100644
index 0000000..ed94ce1
--- /dev/null
+++ b/backend/app/core/utils.py
@@ -0,0 +1,18 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+_tz = ZoneInfo("Asia/Taipei")
+
+
+def configure_timezone(tz_name: str) -> None:
+ """Update the application timezone. Called on startup and when settings change."""
+ global _tz
+ try:
+ _tz = ZoneInfo(tz_name)
+ except Exception:
+ _tz = ZoneInfo("Asia/Taipei")
+
+
+def now_tw() -> datetime:
+ """Return current time in the configured timezone as a naive datetime."""
+ return datetime.now(tz=_tz).replace(tzinfo=None)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index e7d538c..a84ab82 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -3,9 +3,11 @@ from app.models.account import Account
from app.models.schedule import Schedule, ScheduleLog
from app.models.result import TenantScheduleResult, AccountScheduleResult
from app.models.server import Server, ServerStatusLog, SystemStatusLog
+from app.models.settings import SystemSettings
__all__ = [
"Tenant", "Account", "Schedule", "ScheduleLog",
"TenantScheduleResult", "AccountScheduleResult",
"Server", "ServerStatusLog", "SystemStatusLog",
+ "SystemSettings",
]
diff --git a/backend/app/models/account.py b/backend/app/models/account.py
index d6f5248..0390819 100644
--- a/backend/app/models/account.py
+++ b/backend/app/models/account.py
@@ -1,4 +1,5 @@
from datetime import datetime
+from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -21,8 +22,8 @@ class Account(Base):
default_password = Column(String(200))
seq_no = Column(Integer, nullable=False) # 同租戶內流水號
- created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
- updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
+ created_at = Column(DateTime, nullable=False, default=now_tw)
+ updated_at = Column(DateTime, nullable=False, default=now_tw, onupdate=now_tw)
tenant = relationship("Tenant", back_populates="accounts")
schedule_results = relationship("AccountScheduleResult", back_populates="account")
diff --git a/backend/app/models/result.py b/backend/app/models/result.py
index 9ecc176..6dc1c49 100644
--- a/backend/app/models/result.py
+++ b/backend/app/models/result.py
@@ -1,4 +1,5 @@
from datetime import datetime
+from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -28,7 +29,7 @@ class TenantScheduleResult(Base):
fail_reason = Column(Text)
quota_usage = Column(Float) # GB
- recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+ recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="tenant_results")
tenant = relationship("Tenant", back_populates="schedule_results")
@@ -52,9 +53,12 @@ class AccountScheduleResult(Base):
nc_result = Column(Boolean)
nc_done_at = Column(DateTime)
+ nc_mail_result = Column(Boolean)
+ nc_mail_done_at = Column(DateTime)
+
fail_reason = Column(Text)
quota_usage = Column(Float) # GB
- recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+ recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="account_results")
account = relationship("Account", back_populates="schedule_results")
diff --git a/backend/app/models/schedule.py b/backend/app/models/schedule.py
index ec4b954..c25e917 100644
--- a/backend/app/models/schedule.py
+++ b/backend/app/models/schedule.py
@@ -1,4 +1,5 @@
from datetime import datetime
+from app.core.utils import now_tw
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -14,7 +15,7 @@ class Schedule(Base):
last_run_at = Column(DateTime)
next_run_at = Column(DateTime)
last_status = Column(String(10)) # ok / error
- recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+ recorded_at = Column(DateTime, nullable=False, default=now_tw)
logs = relationship("ScheduleLog", back_populates="schedule")
diff --git a/backend/app/models/server.py b/backend/app/models/server.py
index 04088b7..806526b 100644
--- a/backend/app/models/server.py
+++ b/backend/app/models/server.py
@@ -1,4 +1,5 @@
from datetime import datetime
+from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Float, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -13,7 +14,7 @@ class Server(Base):
description = Column(String(200))
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
- recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+ recorded_at = Column(DateTime, nullable=False, default=now_tw)
status_logs = relationship("ServerStatusLog", back_populates="server")
@@ -27,7 +28,7 @@ class ServerStatusLog(Base):
result = Column(Boolean, nullable=False)
response_time = Column(Float) # ms
fail_reason = Column(Text)
- recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+ recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="server_status_logs")
server = relationship("Server", back_populates="status_logs")
@@ -43,6 +44,6 @@ class SystemStatusLog(Base):
service_desc = Column(String(100))
result = Column(Boolean, nullable=False)
fail_reason = Column(Text)
- recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+ recorded_at = Column(DateTime, nullable=False, default=now_tw)
schedule_log = relationship("ScheduleLog", back_populates="system_status_logs")
diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py
new file mode 100644
index 0000000..c999f9c
--- /dev/null
+++ b/backend/app/models/settings.py
@@ -0,0 +1,22 @@
+from datetime import datetime
+from sqlalchemy import Column, Integer, String, Boolean, DateTime
+from app.core.database import Base
+from app.core.utils import now_tw
+
+
+class SystemSettings(Base):
+ __tablename__ = "system_settings"
+
+ id = Column(Integer, primary_key=True, default=1)
+ site_title = Column(String(200), nullable=False, default="VMIS Admin Portal")
+ version = Column(String(50), nullable=False, default="2.0.0")
+ timezone = Column(String(100), nullable=False, default="Asia/Taipei")
+ sso_enabled = Column(Boolean, nullable=False, default=False)
+ # Keycloak — master realm admin (for tenant realm management)
+ keycloak_url = Column(String(200), nullable=False, default="https://auth.lab.taipei")
+ keycloak_admin_user = Column(String(100), nullable=False, default="admin")
+ keycloak_admin_pass = Column(String(200), nullable=False, default="")
+ # Keycloak — Admin Portal SSO
+ keycloak_realm = Column(String(100), nullable=False, default="vmis-admin")
+ keycloak_client = Column(String(100), nullable=False, default="vmis-portal")
+ updated_at = Column(DateTime, nullable=False, default=now_tw, onupdate=now_tw)
diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py
index 6738d62..6cff0da 100644
--- a/backend/app/models/tenant.py
+++ b/backend/app/models/tenant.py
@@ -1,4 +1,5 @@
from datetime import datetime
+from app.core.utils import now_tw
from sqlalchemy import Boolean, Column, Integer, String, Text, DateTime, Date
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -30,8 +31,8 @@ class Tenant(Base):
is_active = Column(Boolean, nullable=False, default=True)
status = Column(String(20), nullable=False, default="trial") # trial / active / inactive
note = Column(Text)
- created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
- updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
+ created_at = Column(DateTime, nullable=False, default=now_tw)
+ updated_at = Column(DateTime, nullable=False, default=now_tw, onupdate=now_tw)
accounts = relationship("Account", back_populates="tenant", cascade="all, delete-orphan")
schedule_results = relationship("TenantScheduleResult", back_populates="tenant")
diff --git a/backend/app/schemas/account.py b/backend/app/schemas/account.py
index 923c9b3..6882985 100644
--- a/backend/app/schemas/account.py
+++ b/backend/app/schemas/account.py
@@ -32,6 +32,7 @@ class AccountStatusLight(BaseModel):
sso_result: Optional[bool] = None
mailbox_result: Optional[bool] = None
nc_result: Optional[bool] = None
+ nc_mail_result: Optional[bool] = None
quota_usage: Optional[float] = None
diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py
index ee779cf..ddff5f9 100644
--- a/backend/app/schemas/schedule.py
+++ b/backend/app/schemas/schedule.py
@@ -1,5 +1,5 @@
from datetime import datetime
-from typing import Optional
+from typing import Optional, List
from pydantic import BaseModel
@@ -31,3 +31,39 @@ class ScheduleLogResponse(BaseModel):
class Config:
from_attributes = True
+
+
+class TenantResultItem(BaseModel):
+ tenant_id: int
+ tenant_name: Optional[str] = None
+ traefik_status: Optional[bool] = None
+ sso_result: Optional[bool] = None
+ mailbox_result: Optional[bool] = None
+ nc_result: Optional[bool] = None
+ office_result: Optional[bool] = None
+ quota_usage: Optional[float] = None
+ fail_reason: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class AccountResultItem(BaseModel):
+ account_id: int
+ sso_account: Optional[str] = None
+ sso_result: Optional[bool] = None
+ mailbox_result: Optional[bool] = None
+ nc_result: Optional[bool] = None
+ nc_mail_result: Optional[bool] = None
+ quota_usage: Optional[float] = None
+ fail_reason: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class LogResultsResponse(BaseModel):
+ log_id: int
+ schedule_id: int
+ tenant_results: List[TenantResultItem] = []
+ account_results: List[AccountResultItem] = []
diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py
new file mode 100644
index 0000000..667ac1d
--- /dev/null
+++ b/backend/app/schemas/settings.py
@@ -0,0 +1,31 @@
+from datetime import datetime
+from typing import Optional
+from pydantic import BaseModel
+
+
+class SettingsUpdate(BaseModel):
+ site_title: Optional[str] = None
+ version: Optional[str] = None
+ timezone: Optional[str] = None
+ sso_enabled: Optional[bool] = None
+ keycloak_url: Optional[str] = None
+ keycloak_admin_user: Optional[str] = None
+ keycloak_admin_pass: Optional[str] = None
+ keycloak_realm: Optional[str] = None
+ keycloak_client: Optional[str] = None
+
+
+class SettingsResponse(BaseModel):
+ id: int
+ site_title: str
+ version: str
+ timezone: str
+ sso_enabled: bool
+ keycloak_url: str
+ keycloak_admin_user: str
+ keycloak_admin_pass: str
+ keycloak_realm: str
+ keycloak_client: str
+ updated_at: Optional[datetime] = None
+
+ model_config = {"from_attributes": True}
diff --git a/backend/app/services/docker_client.py b/backend/app/services/docker_client.py
index dcbc516..cb5f294 100644
--- a/backend/app/services/docker_client.py
+++ b/backend/app/services/docker_client.py
@@ -1,6 +1,7 @@
"""
-DockerClient — docker-py (本機 Docker socket) + paramiko SSH (遠端 docker compose)
-管理租戶的 NC / OO 容器。
+DockerClient — paramiko SSH (遠端 docker / traefik 查詢)
+所有容器都在 10.1.0.254,透過 SSH 操作。
+3-state 回傳: None=未設定(灰), True=正常(綠), False=異常(紅)
"""
import logging
from typing import Optional
@@ -12,70 +13,74 @@ logger = logging.getLogger(__name__)
class DockerClient:
- def __init__(self):
- self._docker = None
- def _get_docker(self):
- if self._docker is None:
- import docker
- self._docker = docker.from_env()
- return self._docker
+ def _ssh(self):
+ """建立 SSH 連線到 10.1.0.254"""
+ import paramiko
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(
+ settings.DOCKER_SSH_HOST,
+ username=settings.DOCKER_SSH_USER,
+ timeout=15,
+ )
+ return client
- def check_traefik_route(self, domain: str) -> bool:
+ def check_traefik_domain(self, domain: str) -> Optional[bool]:
"""
- Traefik API: GET http://localhost:8080/api/http/routers
- 驗證 routers 中包含 domain,且 routers 數量 > 0
+ None = domain 在 Traefik 沒有路由設定(灰)
+ True = 路由存在且服務存活(綠)
+ False = 路由存在但服務不通(紅)
"""
try:
- resp = httpx.get("http://localhost:8080/api/overview", timeout=5.0)
+ resp = httpx.get(f"http://{settings.DOCKER_SSH_HOST}:8080/api/http/routers", timeout=5.0)
if resp.status_code != 200:
return False
- data = resp.json()
- # Verify actual routes exist (functional check)
- http_count = data.get("http", {}).get("routers", {}).get("total", 0)
- if http_count == 0:
+ routers = resp.json()
+ route_found = any(domain in str(r.get("rule", "")) for r in routers)
+ if not route_found:
+ return None
+ # Route exists — probe the service
+ try:
+ probe = httpx.get(
+ f"https://{domain}",
+ timeout=5.0,
+ follow_redirects=True,
+ )
+ return probe.status_code < 500
+ except Exception:
return False
- # Check domain-specific router
- routers_resp = httpx.get("http://localhost:8080/api/http/routers", timeout=5.0)
- if routers_resp.status_code != 200:
- return False
- routers = routers_resp.json()
- return any(domain in str(r.get("rule", "")) for r in routers)
except Exception as e:
logger.warning(f"Traefik check failed for {domain}: {e}")
return False
- def ensure_container_running(self, container_name: str, tenant_code: str, realm: str) -> bool:
- """Check container status; start if exited; deploy via SSH if not found."""
+ def check_container_ssh(self, container_name: str) -> Optional[bool]:
+ """
+ SSH 到 10.1.0.254 查詢容器狀態。
+ None = 容器不存在(未部署)
+ True = 容器正在執行
+ False = 容器存在但未執行(exited/paused)
+ """
try:
- docker_client = self._get_docker()
- container = docker_client.containers.get(container_name)
- if container.status == "running":
- return True
- elif container.status == "exited":
- container.start()
- container.reload()
- return container.status == "running"
- except Exception as e:
- if "Not Found" in str(e) or "404" in str(e):
- return self._ssh_compose_up(tenant_code, realm)
- logger.error(f"Docker check failed for {container_name}: {e}")
- return False
- return False
-
- def _ssh_compose_up(self, tenant_code: str, realm: str) -> bool:
- """SSH into 10.1.0.254 and run docker compose up -d"""
- try:
- import paramiko
- client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- client.connect(
- settings.DOCKER_SSH_HOST,
- username=settings.DOCKER_SSH_USER,
- timeout=15,
+ client = self._ssh()
+ _, stdout, _ = client.exec_command(
+ f"docker inspect --format='{{{{.State.Status}}}}' {container_name} 2>/dev/null"
)
+ output = stdout.read().decode().strip()
+ client.close()
+ if not output:
+ return None
+ return output == "running"
+ except Exception as e:
+ logger.error(f"SSH container check failed for {container_name}: {e}")
+ return False
+
+ def ssh_compose_up(self, tenant_code: str) -> bool:
+ """SSH 到 10.1.0.254 執行 docker compose up -d"""
+ try:
+ client = self._ssh()
deploy_dir = f"{settings.TENANT_DEPLOY_BASE}/{tenant_code}"
- stdin, stdout, stderr = client.exec_command(
+ _, stdout, _ = client.exec_command(
f"cd {deploy_dir} && docker compose up -d 2>&1"
)
exit_status = stdout.channel.recv_exit_status()
@@ -84,3 +89,19 @@ class DockerClient:
except Exception as e:
logger.error(f"SSH compose up failed for {tenant_code}: {e}")
return False
+
+ def get_oo_disk_usage_gb(self, container_name: str) -> Optional[float]:
+ """取得 OO 容器磁碟使用量(GB),容器不存在回傳 None"""
+ try:
+ client = self._ssh()
+ _, stdout, _ = client.exec_command(
+ f"docker exec {container_name} df -B1 / 2>/dev/null | awk 'NR==2 {{print $3}}'"
+ )
+ output = stdout.read().decode().strip()
+ client.close()
+ if output.isdigit():
+ return round(int(output) / (1024 ** 3), 3)
+ return None
+ except Exception as e:
+ logger.warning(f"OO disk usage check failed for {container_name}: {e}")
+ return None
diff --git a/backend/app/services/keycloak_client.py b/backend/app/services/keycloak_client.py
index f86833b..096be30 100644
--- a/backend/app/services/keycloak_client.py
+++ b/backend/app/services/keycloak_client.py
@@ -1,34 +1,36 @@
"""
KeycloakClient — 直接呼叫 Keycloak REST API,不使用 python-keycloak 套件。
-管理租戶 realm 及帳號的建立/查詢。
+使用 master realm admin 帳密取得管理 token,管理租戶 realm 及帳號。
"""
import logging
from typing import Optional
import httpx
-from app.core.config import settings
-
logger = logging.getLogger(__name__)
TIMEOUT = 10.0
class KeycloakClient:
- def __init__(self):
- self._base = settings.KEYCLOAK_URL.rstrip("/")
+ def __init__(self, base_url: str, admin_user: str, admin_pass: str):
+ self._base = base_url.rstrip("/")
+ self._admin_user = admin_user
+ self._admin_pass = admin_pass
self._admin_token: Optional[str] = None
def _get_admin_token(self) -> str:
- """取得 vmis-admin realm 的 admin access token"""
- url = f"{self._base}/realms/{settings.KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token"
+ """取得 master realm 的 admin access token(Resource Owner Password)"""
+ url = f"{self._base}/realms/master/protocol/openid-connect/token"
resp = httpx.post(
url,
data={
- "grant_type": "client_credentials",
- "client_id": settings.KEYCLOAK_ADMIN_CLIENT_ID,
- "client_secret": settings.KEYCLOAK_ADMIN_CLIENT_SECRET,
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": self._admin_user,
+ "password": self._admin_pass,
},
timeout=TIMEOUT,
+ verify=False,
)
resp.raise_for_status()
return resp.json()["access_token"]
@@ -43,7 +45,12 @@ class KeycloakClient:
def realm_exists(self, realm: str) -> bool:
try:
- resp = httpx.get(self._admin_url(realm), headers=self._headers(), timeout=TIMEOUT)
+ resp = httpx.get(
+ self._admin_url(realm),
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
return resp.status_code == 200
except Exception:
return False
@@ -60,21 +67,58 @@ class KeycloakClient:
json=payload,
headers=self._headers(),
timeout=TIMEOUT,
+ verify=False,
)
return resp.status_code in (201, 204)
+ def update_realm_token_settings(self, realm: str, access_code_lifespan: int = 600) -> bool:
+ """設定 realm 的授權碼有效期(秒),預設 10 分鐘"""
+ resp = httpx.put(
+ self._admin_url(realm),
+ json={
+ "accessCodeLifespan": access_code_lifespan,
+ "actionTokenGeneratedByUserLifespan": access_code_lifespan,
+ },
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ return resp.status_code in (200, 204)
+
+ def send_welcome_email(self, realm: str, user_id: str) -> bool:
+ """寄送歡迎信(含設定密碼連結)給新使用者"""
+ try:
+ resp = httpx.put(
+ self._admin_url(f"{realm}/users/{user_id}/execute-actions-email"),
+ json=["UPDATE_PASSWORD"],
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ return resp.status_code in (200, 204)
+ except Exception as e:
+ logger.error(f"KC send_welcome_email({realm}/{user_id}) failed: {e}")
+ return False
+
def get_user_uuid(self, realm: str, username: str) -> Optional[str]:
resp = httpx.get(
self._admin_url(f"{realm}/users"),
params={"username": username, "exact": "true"},
headers=self._headers(),
timeout=TIMEOUT,
+ verify=False,
)
resp.raise_for_status()
users = resp.json()
return users[0]["id"] if users else None
- def create_user(self, realm: str, username: str, email: str, password: Optional[str]) -> Optional[str]:
+ def create_user(
+ self,
+ realm: str,
+ username: str,
+ email: str,
+ password: Optional[str],
+ ) -> Optional[str]:
payload = {
"username": username,
"email": email,
@@ -82,14 +126,121 @@ class KeycloakClient:
"emailVerified": True,
}
if password:
- payload["credentials"] = [{"type": "password", "value": password, "temporary": True}]
+ payload["credentials"] = [
+ {"type": "password", "value": password, "temporary": True}
+ ]
resp = httpx.post(
self._admin_url(f"{realm}/users"),
json=payload,
headers=self._headers(),
timeout=TIMEOUT,
+ verify=False,
)
if resp.status_code == 201:
location = resp.headers.get("Location", "")
return location.rstrip("/").split("/")[-1]
return None
+
+ def create_public_client(self, realm: str, client_id: str) -> str:
+ """建立 Public Client。回傳 'exists' / 'created' / 'failed'"""
+ resp = httpx.get(
+ self._admin_url(f"{realm}/clients"),
+ params={"clientId": client_id},
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ resp.raise_for_status()
+ if resp.json():
+ return "exists"
+ payload = {
+ "clientId": client_id,
+ "name": client_id,
+ "enabled": True,
+ "publicClient": True,
+ "standardFlowEnabled": True,
+ "directAccessGrantsEnabled": False,
+ "redirectUris": ["*"],
+ "webOrigins": ["*"],
+ }
+ resp = httpx.post(
+ self._admin_url(f"{realm}/clients"),
+ json=payload,
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ return "created" if resp.status_code in (201, 204) else "failed"
+
+ def create_confidential_client(self, realm: str, client_id: str, redirect_uris: list[str]) -> str:
+ """建立 Confidential Client(用於 NC OIDC)。回傳 'exists' / 'created' / 'failed'"""
+ resp = httpx.get(
+ self._admin_url(f"{realm}/clients"),
+ params={"clientId": client_id},
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ resp.raise_for_status()
+ if resp.json():
+ return "exists"
+ origin = redirect_uris[0].rstrip("/*") if redirect_uris else "*"
+ payload = {
+ "clientId": client_id,
+ "name": client_id,
+ "enabled": True,
+ "publicClient": False,
+ "standardFlowEnabled": True,
+ "directAccessGrantsEnabled": False,
+ "redirectUris": redirect_uris,
+ "webOrigins": [origin],
+ "protocol": "openid-connect",
+ }
+ resp = httpx.post(
+ self._admin_url(f"{realm}/clients"),
+ json=payload,
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ return "created" if resp.status_code in (201, 204) else "failed"
+
+ def get_client_secret(self, realm: str, client_id: str) -> Optional[str]:
+ """取得 Confidential Client 的 Secret"""
+ resp = httpx.get(
+ self._admin_url(f"{realm}/clients"),
+ params={"clientId": client_id},
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ resp.raise_for_status()
+ clients = resp.json()
+ if not clients:
+ return None
+ client_uuid = clients[0]["id"]
+ resp = httpx.get(
+ self._admin_url(f"{realm}/clients/{client_uuid}/client-secret"),
+ headers=self._headers(),
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ if resp.status_code == 200:
+ return resp.json().get("value")
+ return None
+
+
+def get_keycloak_client() -> KeycloakClient:
+ """Factory: reads credentials from system settings DB."""
+ from app.core.database import SessionLocal
+ from app.models.settings import SystemSettings
+
+ db = SessionLocal()
+ try:
+ s = db.query(SystemSettings).first()
+ if s:
+ return KeycloakClient(s.keycloak_url, s.keycloak_admin_user, s.keycloak_admin_pass)
+ finally:
+ db.close()
+ # Fallback to defaults
+ return KeycloakClient("https://auth.lab.taipei", "admin", "")
diff --git a/backend/app/services/mail_client.py b/backend/app/services/mail_client.py
index 9a195e9..fab301e 100644
--- a/backend/app/services/mail_client.py
+++ b/backend/app/services/mail_client.py
@@ -1,24 +1,36 @@
"""
-MailClient — 呼叫 Docker Mailserver Admin API (http://10.1.0.254:8080)
-管理 mail domain 和 mailbox 的建立/查詢。
-建立 domain 前必須驗證 MX DNS 設定(對 active 租戶)。
+MailClient — 透過 SSH + docker exec mailserver 管理 docker-mailserver。
+domain 在 docker-mailserver 中是隱式的(由 mailbox 決定),
+所以 domain_exists 檢查是否有任何 @domain 的 mailbox。
"""
import logging
from typing import Optional
-import httpx
import dns.resolver
from app.core.config import settings
logger = logging.getLogger(__name__)
-TIMEOUT = 10.0
+MAILSERVER_CONTAINER = "mailserver"
class MailClient:
- def __init__(self):
- self._base = settings.MAIL_ADMIN_API_URL.rstrip("/")
- self._headers = {"X-API-Key": settings.MAIL_ADMIN_API_KEY}
+
+ def _ssh_exec(self, cmd: str) -> tuple[int, str]:
+ """SSH 到 10.1.0.254 執行指令,回傳 (exit_code, stdout)"""
+ import paramiko
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(
+ settings.DOCKER_SSH_HOST,
+ username=settings.DOCKER_SSH_USER,
+ timeout=15,
+ )
+ _, stdout, _ = client.exec_command(cmd)
+ output = stdout.read().decode().strip()
+ exit_code = stdout.channel.recv_exit_status()
+ client.close()
+ return exit_code, output
def check_mx_dns(self, domain: str) -> bool:
"""驗證 domain 的 MX record 是否指向正確的 mail server"""
@@ -33,49 +45,65 @@ class MailClient:
return False
def domain_exists(self, domain: str) -> bool:
+ """檢查 mailserver 是否有任何 @domain 的 mailbox(docker-mailserver 的 domain 由 mailbox 決定)"""
try:
- resp = httpx.get(
- f"{self._base}/api/v1/domains/{domain}",
- headers=self._headers,
- timeout=TIMEOUT,
+ code, output = self._ssh_exec(
+ f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '@{domain}' && echo yes || echo no"
)
- return resp.status_code == 200
- except Exception:
+ return output.strip() == "yes"
+ except Exception as e:
+ logger.warning(f"domain_exists({domain}) SSH failed: {e}")
return False
def create_domain(self, domain: str) -> bool:
+ """
+ docker-mailserver 不需要顯式建立 domain(mailbox 新增時自動處理)。
+ 新增一個 postmaster@ 系統帳號來確保 domain 被識別。
+ """
try:
- resp = httpx.post(
- f"{self._base}/api/v1/domains",
- json={"domain": domain},
- headers=self._headers,
- timeout=TIMEOUT,
+ import secrets
+ passwd = secrets.token_urlsafe(16)
+ code, output = self._ssh_exec(
+ f"docker exec {MAILSERVER_CONTAINER} setup email add postmaster@{domain} {passwd} 2>&1"
)
- return resp.status_code in (200, 201, 204)
+ if code == 0:
+ logger.info(f"create_domain({domain}): postmaster account created")
+ return True
+ # 若帳號已存在視為成功
+ if "already exists" in output.lower():
+ return True
+ logger.error(f"create_domain({domain}) failed (exit {code}): {output}")
+ return False
except Exception as e:
- logger.error(f"create_domain({domain}) failed: {e}")
+ logger.error(f"create_domain({domain}) SSH failed: {e}")
return False
def mailbox_exists(self, email: str) -> bool:
try:
- resp = httpx.get(
- f"{self._base}/api/v1/mailboxes/{email}",
- headers=self._headers,
- timeout=TIMEOUT,
+ code, output = self._ssh_exec(
+ f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '{email}' && echo yes || echo no"
)
- return resp.status_code == 200
- except Exception:
+ return output.strip() == "yes"
+ except Exception as e:
+ logger.warning(f"mailbox_exists({email}) SSH failed: {e}")
return False
def create_mailbox(self, email: str, password: Optional[str], quota_gb: int = 20) -> bool:
try:
- resp = httpx.post(
- f"{self._base}/api/v1/mailboxes",
- json={"email": email, "password": password or "", "quota": quota_gb},
- headers=self._headers,
- timeout=TIMEOUT,
+ import secrets
+ passwd = password or secrets.token_urlsafe(16)
+ quota_mb = quota_gb * 1024
+ code, output = self._ssh_exec(
+ f"docker exec {MAILSERVER_CONTAINER} setup email add {email} {passwd} 2>&1"
)
- return resp.status_code in (200, 201, 204)
+ if code != 0 and "already exists" not in output.lower():
+ logger.error(f"create_mailbox({email}) failed (exit {code}): {output}")
+ return False
+ # 設定配額
+ self._ssh_exec(
+ f"docker exec {MAILSERVER_CONTAINER} setup quota set {email} {quota_mb}M 2>/dev/null"
+ )
+ return True
except Exception as e:
- logger.error(f"create_mailbox({email}) failed: {e}")
+ logger.error(f"create_mailbox({email}) SSH failed: {e}")
return False
diff --git a/backend/app/services/nextcloud_client.py b/backend/app/services/nextcloud_client.py
index 7159290..b258319 100644
--- a/backend/app/services/nextcloud_client.py
+++ b/backend/app/services/nextcloud_client.py
@@ -47,6 +47,20 @@ class NextcloudClient:
logger.error(f"NC create_user({username}) failed: {e}")
return False
+ def set_user_quota(self, username: str, quota_gb: int) -> bool:
+ try:
+ resp = httpx.put(
+ f"{self._base}/ocs/v1.php/cloud/users/{username}",
+ auth=self._auth,
+ headers=OCS_HEADERS,
+ data={"key": "quota", "value": f"{quota_gb}GB"},
+ timeout=TIMEOUT,
+ )
+ return resp.status_code == 200
+ except Exception as e:
+ logger.error(f"NC set_user_quota({username}) failed: {e}")
+ return False
+
def get_user_quota_used_gb(self, username: str) -> Optional[float]:
try:
resp = httpx.get(
@@ -57,11 +71,57 @@ class NextcloudClient:
)
if resp.status_code != 200:
return None
- used_bytes = resp.json().get("ocs", {}).get("data", {}).get("quota", {}).get("used", 0)
+ quota = resp.json().get("ocs", {}).get("data", {}).get("quota", {})
+ if not isinstance(quota, dict):
+ return 0.0
+ used_bytes = quota.get("used", 0) or 0
return round(used_bytes / 1073741824, 4)
except Exception:
return None
+ def subscribe_calendar(
+ self,
+ username: str,
+ cal_slug: str,
+ display_name: str,
+ ics_url: str,
+ color: str = "#e9322d",
+ ) -> bool:
+ """
+ 為使用者建立訂閱行事曆(CalDAV MKCALENDAR with calendarserver source)。
+ 已存在(405)視為成功;201=新建成功。
+ """
+ try:
+ body = (
+ '\n'
+ '\n'
+ ' \n'
+ ' \n'
+ f' {display_name}\n'
+ f' {color}\n'
+ ' \n'
+ f' {ics_url}\n'
+ ' \n'
+ ' \n'
+ ' \n'
+ ''
+ )
+ url = f"{self._base}/remote.php/dav/calendars/{username}/{cal_slug}/"
+ resp = httpx.request(
+ "MKCALENDAR",
+ url,
+ auth=self._auth,
+ content=body.encode("utf-8"),
+ headers={"Content-Type": "application/xml; charset=utf-8"},
+ timeout=TIMEOUT,
+ verify=False,
+ )
+ # 201=建立成功, 405=已存在(Method Not Allowed on existing resource)
+ return resp.status_code in (201, 405)
+ except Exception as e:
+ logger.error(f"NC subscribe_calendar({username}, {cal_slug}) failed: {e}")
+ return False
+
def get_total_quota_used_gb(self) -> Optional[float]:
"""Sum all users' quota usage"""
try:
diff --git a/backend/app/services/scheduler/runner.py b/backend/app/services/scheduler/runner.py
index 8be4bd6..7a8b5a6 100644
--- a/backend/app/services/scheduler/runner.py
+++ b/backend/app/services/scheduler/runner.py
@@ -17,28 +17,33 @@ def dispatch_schedule(schedule_id: int, log_id: int = None, db: Session = None):
When called from manual API, creates its own session and log.
"""
own_db = db is None
+ own_log = False
+ log_obj = None
+
if own_db:
db = SessionLocal()
if log_id is None:
- from datetime import datetime
+ from app.core.utils import now_tw
from app.models.schedule import ScheduleLog, Schedule
schedule = db.get(Schedule, schedule_id)
if not schedule:
if own_db:
db.close()
return
- log = ScheduleLog(
+ log_obj = ScheduleLog(
schedule_id=schedule_id,
schedule_name=schedule.name,
- started_at=datetime.utcnow(),
+ started_at=now_tw(),
status="running",
)
- db.add(log)
+ db.add(log_obj)
db.commit()
- db.refresh(log)
- log_id = log.id
+ db.refresh(log_obj)
+ log_id = log_obj.id
+ own_log = True
+ final_status = "error"
try:
if schedule_id == 1:
from app.services.scheduler.schedule_tenant import run_tenant_check
@@ -51,9 +56,16 @@ def dispatch_schedule(schedule_id: int, log_id: int = None, db: Session = None):
run_system_status(log_id, db)
else:
logger.warning(f"Unknown schedule_id: {schedule_id}")
+ final_status = "ok"
except Exception as e:
logger.exception(f"dispatch_schedule({schedule_id}) error: {e}")
raise
finally:
+ # When called from manual trigger (own_log), finalize the log entry
+ if own_log and log_obj is not None:
+ from app.core.utils import now_tw
+ log_obj.ended_at = now_tw()
+ log_obj.status = final_status
+ db.commit()
if own_db:
db.close()
diff --git a/backend/app/services/scheduler/schedule_account.py b/backend/app/services/scheduler/schedule_account.py
index 0eaa303..98a765b 100644
--- a/backend/app/services/scheduler/schedule_account.py
+++ b/backend/app/services/scheduler/schedule_account.py
@@ -4,6 +4,7 @@ Schedule 2 — 帳號檢查(每 3 分鐘)
"""
import logging
from datetime import datetime
+from app.core.utils import now_tw
from sqlalchemy.orm import Session
from app.models.account import Account
@@ -13,16 +14,17 @@ logger = logging.getLogger(__name__)
def run_account_check(schedule_log_id: int, db: Session):
- from app.services.keycloak_client import KeycloakClient
from app.services.mail_client import MailClient
from app.services.nextcloud_client import NextcloudClient
+ from app.services.keycloak_client import get_keycloak_client
+
accounts = (
db.query(Account)
.filter(Account.is_active == True)
.all()
)
- kc = KeycloakClient()
+ kc = get_keycloak_client()
mail = MailClient()
for account in accounts:
@@ -32,7 +34,7 @@ def run_account_check(schedule_log_id: int, db: Session):
schedule_log_id=schedule_log_id,
account_id=account.id,
sso_account=account.sso_account,
- recorded_at=datetime.utcnow(),
+ recorded_at=now_tw(),
)
fail_reasons = []
@@ -45,15 +47,19 @@ def run_account_check(schedule_log_id: int, db: Session):
if not account.sso_uuid:
account.sso_uuid = sso_uuid
else:
- sso_uuid = kc.create_user(realm, account.sso_account, account.email, account.default_password)
+ kc_email = account.notification_email or account.email
+ sso_uuid = kc.create_user(realm, account.sso_account, kc_email, account.default_password)
result.sso_result = sso_uuid is not None
result.sso_uuid = sso_uuid
if sso_uuid and not account.sso_uuid:
account.sso_uuid = sso_uuid
- result.sso_done_at = datetime.utcnow()
+ # 新使用者:寄送歡迎信(含設定密碼連結)
+ if account.notification_email:
+ kc.send_welcome_email(realm, sso_uuid)
+ result.sso_done_at = now_tw()
except Exception as e:
result.sso_result = False
- result.sso_done_at = datetime.utcnow()
+ result.sso_done_at = now_tw()
fail_reasons.append(f"sso: {e}")
# [2] Mailbox check (skip if mail domain not ready)
@@ -65,30 +71,113 @@ def run_account_check(schedule_log_id: int, db: Session):
else:
created = mail.create_mailbox(email, account.default_password, account.quota_limit)
result.mailbox_result = created
- result.mailbox_done_at = datetime.utcnow()
+ result.mailbox_done_at = now_tw()
except Exception as e:
result.mailbox_result = False
- result.mailbox_done_at = datetime.utcnow()
+ result.mailbox_done_at = now_tw()
fail_reasons.append(f"mailbox: {e}")
# [3] NC user check
try:
- nc = NextcloudClient(tenant.domain)
+ from app.core.config import settings as _cfg
+ nc = NextcloudClient(tenant.domain, _cfg.NC_ADMIN_USER, _cfg.NC_ADMIN_PASSWORD)
nc_exists = nc.user_exists(account.sso_account)
if nc_exists:
result.nc_result = True
else:
created = nc.create_user(account.sso_account, account.default_password, account.quota_limit)
result.nc_result = created
- result.nc_done_at = datetime.utcnow()
+ # 確保 quota 設定正確(無論新建或已存在)
+ if result.nc_result and account.quota_limit:
+ nc.set_user_quota(account.sso_account, account.quota_limit)
+ result.nc_done_at = now_tw()
except Exception as e:
result.nc_result = False
- result.nc_done_at = datetime.utcnow()
+ result.nc_done_at = now_tw()
fail_reasons.append(f"nc: {e}")
- # [4] Quota
+ # [3.5] NC 台灣國定假日行事曆訂閱
+ # 前置條件:NC 帳號已存在;MKCALENDAR 為 idempotent(已存在回傳 405 視為成功)
+ TW_HOLIDAYS_ICS_URL = "https://www.officeholidays.com/ics-clean/taiwan"
+ if result.nc_result:
+ try:
+ cal_ok = nc.subscribe_calendar(
+ account.sso_account,
+ "taiwan-public-holidays",
+ "台灣國定假日",
+ TW_HOLIDAYS_ICS_URL,
+ "#e9322d",
+ )
+ if cal_ok:
+ logger.info(f"NC calendar subscribed [{account.sso_account}]: taiwan-public-holidays")
+ else:
+ logger.warning(f"NC calendar subscribe failed [{account.sso_account}]")
+ except Exception as e:
+ logger.warning(f"NC calendar subscribe error [{account.sso_account}]: {e}")
+
+ # [4] NC Mail 帳號設定
+ # 前置條件:NC 帳號已建立 + mailbox 已建立
try:
- nc = NextcloudClient(tenant.domain)
+ if result.nc_result and result.mailbox_result:
+ import paramiko
+ from app.core.config import settings as _cfg
+ is_active = tenant.status == "active"
+ nc_container = f"nc-{tenant.code}" if is_active else f"nc-{tenant.code}-test"
+ email = account.email or f"{account.sso_account}@{tenant.domain}"
+
+ ssh = paramiko.SSHClient()
+ ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ ssh.connect(_cfg.DOCKER_SSH_HOST, username=_cfg.DOCKER_SSH_USER, timeout=15)
+
+ def _ssh_run(cmd, timeout=60):
+ """執行 SSH 指令並回傳輸出,超時返回空字串"""
+ _, stdout, _ = ssh.exec_command(cmd)
+ stdout.channel.settimeout(timeout)
+ try:
+ out = stdout.read().decode().strip()
+ except Exception:
+ out = ""
+ return out
+
+ # 確認是否已設定(occ mail:account:export 回傳帳號列表)
+ export_out = _ssh_run(
+ f"docker exec -u www-data {nc_container} "
+ f"php /var/www/html/occ mail:account:export {account.sso_account} 2>/dev/null"
+ )
+ already_set = len(export_out) > 10 and "imap" in export_out.lower()
+
+ if already_set:
+ result.nc_mail_result = True
+ else:
+ display = account.legal_name or account.sso_account
+ create_cmd = (
+ f"docker exec -u www-data {nc_container} "
+ f"php /var/www/html/occ mail:account:create "
+ f"'{account.sso_account}' '{display}' '{email}' "
+ f"10.1.0.254 143 none '{email}' '{account.default_password}' "
+ f"10.1.0.254 587 none '{email}' '{account.default_password}' 2>&1"
+ )
+ out_text = _ssh_run(create_cmd)
+ logger.info(f"NC mail:account:create [{account.sso_account}]: {out_text}")
+ result.nc_mail_result = (
+ "error" not in out_text.lower() and "exception" not in out_text.lower()
+ )
+
+ ssh.close()
+ else:
+ result.nc_mail_result = False
+ fail_reasons.append(
+ f"nc_mail: skipped (nc={result.nc_result}, mailbox={result.mailbox_result})"
+ )
+ result.nc_mail_done_at = now_tw()
+ except Exception as e:
+ result.nc_mail_result = False
+ result.nc_mail_done_at = now_tw()
+ fail_reasons.append(f"nc_mail: {e}")
+
+ # [5] Quota
+ try:
+ nc = NextcloudClient(tenant.domain, _cfg.NC_ADMIN_USER, _cfg.NC_ADMIN_PASSWORD)
result.quota_usage = nc.get_user_quota_used_gb(account.sso_account)
except Exception as e:
logger.warning(f"Quota check failed for {account.account_code}: {e}")
diff --git a/backend/app/services/scheduler/schedule_system.py b/backend/app/services/scheduler/schedule_system.py
index da27099..96df402 100644
--- a/backend/app/services/scheduler/schedule_system.py
+++ b/backend/app/services/scheduler/schedule_system.py
@@ -5,6 +5,7 @@ Part B: 伺服器 ping 檢查
"""
import logging
from datetime import datetime
+from app.core.utils import now_tw
from sqlalchemy.orm import Session
from app.models.server import SystemStatusLog, ServerStatusLog, Server
@@ -64,7 +65,7 @@ def run_system_status(schedule_log_id: int, db: Session):
service_desc=svc["service_desc"],
result=result,
fail_reason=fail_reason,
- recorded_at=datetime.utcnow(),
+ recorded_at=now_tw(),
))
# Part B: Server ping
@@ -87,7 +88,7 @@ def run_system_status(schedule_log_id: int, db: Session):
result=result,
response_time=response_time,
fail_reason=fail_reason,
- recorded_at=datetime.utcnow(),
+ recorded_at=now_tw(),
))
db.commit()
diff --git a/backend/app/services/scheduler/schedule_tenant.py b/backend/app/services/scheduler/schedule_tenant.py
index 77ffe8f..abe030c 100644
--- a/backend/app/services/scheduler/schedule_tenant.py
+++ b/backend/app/services/scheduler/schedule_tenant.py
@@ -1,109 +1,1011 @@
"""
Schedule 1 — 租戶檢查(每 3 分鐘)
-檢查每個 active 租戶的: Traefik路由 / SSO Realm / Mailbox Domain / NC容器 / OO容器 / Quota
+3-state: None=未設定(灰), True=正常(綠), False=異常(紅)
+- None → 自動嘗試建立/部署,記錄 done_at
+- False → 發送告警 email 給所有管理員
"""
import logging
-from datetime import datetime
+import smtplib
+from email.mime.text import MIMEText
+from typing import Optional
+
+import httpx
from sqlalchemy.orm import Session
+from app.core.config import settings
+from app.core.utils import now_tw
from app.models.tenant import Tenant
from app.models.result import TenantScheduleResult
logger = logging.getLogger(__name__)
+PG_PORT = 5433
+PG_USER = "admin"
+PG_PASS = "DC1qaz2wsx"
+PG_HOST_TRIAL = "10.1.0.20"
+PG_HOST_ACTIVE = "10.1.0.254"
+
+REDIS_HOST_TRIAL = "10.14.0.20"
+REDIS_HOST_ACTIVE = "10.1.0.254"
+REDIS_PORT = 6379
+REDIS_PASS = "DC1qaz2wsx"
+
+KC_HOST_TRIAL = "auth.lab.taipei"
+KC_HOST_ACTIVE = "auth.ease.taipei"
+
+TRAEFIK_DYNAMIC_DIR = "/home/porsche/traefik/dynamic"
+TRAEFIK_API_URL = "http://10.1.0.254:8080"
+
+
+# ─── Docker Compose 範本產生 ─────────────────────────────────────────────────
+
+OO_AI_PLUGIN_GUID = "{9DC93CDB-B576-4F0C-B55E-FCC9C48DD007}"
+OO_AI_TRANSLATIONS_HOST = "/home/porsche/tenants/shared/oo-plugins/ai-translations"
+OO_AI_TRANSLATIONS_CONTAINER = (
+ f"/var/www/onlyoffice/documentserver/sdkjs-plugins/{OO_AI_PLUGIN_GUID}/translations"
+)
+
+
+def _generate_tenant_compose(tenant, is_active: bool) -> str:
+ """
+ 產生租戶 docker-compose.yml 內容。
+ 包含 NC + OO 容器設定,OO 已加入繁中 AI plugin bind mount。
+ """
+ code = tenant.code
+ suffix = "" if is_active else "-test"
+ nc = f"nc-{code}{suffix}"
+ oo = f"oo-{code}{suffix}"
+ pg_host = PG_HOST_ACTIVE if is_active else PG_HOST_TRIAL
+ pg_db = f"nc_{code}_db"
+ nc_domain = tenant.domain
+ oo_host = f"office-{code}.ease.taipei" if is_active else f"office-{code}.lab.taipei"
+
+ ai_base = OO_AI_TRANSLATIONS_HOST
+ ai_cont = OO_AI_TRANSLATIONS_CONTAINER
+
+ return f"""services:
+ {nc}:
+ image: nextcloud:31
+ container_name: {nc}
+ restart: unless-stopped
+ volumes:
+ - {nc}-data:/var/www/html
+ - {nc}-apps:/var/www/html/custom_apps
+ - {nc}-config:/var/www/html/config
+ environment:
+ POSTGRES_HOST: {pg_host}:{PG_PORT}
+ POSTGRES_DB: {pg_db}
+ POSTGRES_USER: {PG_USER}
+ POSTGRES_PASSWORD: ${{NC_DB_PASSWORD}}
+ NEXTCLOUD_ADMIN_USER: ${{NC_ADMIN_USER}}
+ NEXTCLOUD_ADMIN_PASSWORD: ${{NC_ADMIN_PASSWORD}}
+ NEXTCLOUD_TRUSTED_DOMAINS: {nc_domain}
+ OVERWRITEPROTOCOL: https
+ TRUSTED_PROXIES: 172.18.0.0/16
+ TZ: Asia/Taipei
+ networks:
+ - traefik-network
+ labels:
+ - "traefik.enable=false"
+
+ {oo}:
+ image: onlyoffice/documentserver:latest
+ container_name: {oo}
+ restart: unless-stopped
+ environment:
+ - JWT_SECRET=${{OO_JWT_SECRET}}
+ volumes:
+ - {oo}-data:/var/www/onlyoffice/Data
+ - {oo}-log:/var/log/onlyoffice
+ - {ai_base}/zh-TW.json:{ai_cont}/zh-TW.json:ro
+ - {ai_base}/zh-TW.json.gz:{ai_cont}/zh-TW.json.gz:ro
+ - {ai_base}/langs.json:{ai_cont}/langs.json:ro
+ - {ai_base}/langs.json.gz:{ai_cont}/langs.json.gz:ro
+ networks:
+ - traefik-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.docker.network=traefik-network"
+ - "traefik.http.routers.{oo}.rule=Host(`{oo_host}`)"
+ - "traefik.http.routers.{oo}.entrypoints=websecure"
+ - "traefik.http.routers.{oo}.tls=true"
+ - "traefik.http.routers.{oo}.tls.certresolver=letsencrypt"
+ - "traefik.http.services.{oo}.loadbalancer.server.port=80"
+ - "traefik.http.middlewares.{oo}-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
+ - "traefik.http.routers.{oo}.middlewares={oo}-headers"
+
+networks:
+ traefik-network:
+ external: true
+
+volumes:
+ {nc}-data:
+ {nc}-apps:
+ {nc}-config:
+ {oo}-data:
+ {oo}-log:
+"""
+
+
+def _ensure_tenant_compose(tenant, is_active: bool) -> bool:
+ """
+ 確保租戶 docker-compose.yml 存在且為最新範本。
+ 若不存在則自動產生並寫入,同時補齊 .env。
+ """
+ try:
+ import paramiko
+ code = tenant.code
+ deploy_dir = f"{settings.TENANT_DEPLOY_BASE}/{code}"
+ compose_path = f"{deploy_dir}/docker-compose.yml"
+ env_path = f"{deploy_dir}/.env"
+
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(settings.DOCKER_SSH_HOST, username=settings.DOCKER_SSH_USER, timeout=15)
+
+ # 確保目錄存在
+ _, out, _ = client.exec_command(f"mkdir -p {deploy_dir}")
+ out.channel.recv_exit_status()
+
+ # 若 docker-compose.yml 不存在才寫入(避免覆蓋手動調整)
+ _, stdout, _ = client.exec_command(f"test -f {compose_path} && echo exists || echo missing")
+ exists = stdout.read().decode().strip() == "exists"
+
+ if not exists:
+ content = _generate_tenant_compose(tenant, is_active)
+ sftp = client.open_sftp()
+ with sftp.open(compose_path, "w") as f:
+ f.write(content)
+ sftp.close()
+ logger.info(f"docker-compose.yml generated for {code}")
+
+ # 確保 .env 存在(含必要變數)
+ _, stdout2, _ = client.exec_command(f"test -f {env_path} && echo exists || echo missing")
+ env_exists = stdout2.read().decode().strip() == "exists"
+ if not env_exists:
+ from app.core.config import settings as _cfg
+ env_content = (
+ f"NC_DB_PASSWORD={PG_PASS}\n"
+ f"NC_ADMIN_USER={_cfg.NC_ADMIN_USER}\n"
+ f"NC_ADMIN_PASSWORD={_cfg.NC_ADMIN_PASSWORD}\n"
+ f"OO_JWT_SECRET={_cfg.OO_JWT_SECRET}\n"
+ )
+ sftp2 = client.open_sftp()
+ with sftp2.open(env_path, "w") as f:
+ f.write(env_content)
+ sftp2.close()
+ logger.info(f".env generated for {code}")
+
+ client.close()
+ return True
+ except Exception as e:
+ logger.error(f"_ensure_tenant_compose {tenant.code}: {e}")
+ return False
+
+
+# ─── Traefik file provider helpers ───────────────────────────────────────────
+
+def _generate_tenant_route_yaml(tenant, is_active: bool) -> str:
+ """
+ 產生租戶 Traefik 路由 YAML 內容。
+ 租戶網域根路徑直接指向 NC(無路徑前綴)。
+ is_manager=true 的租戶額外加入 /admin 路由(priority 200,比 drive 高)。
+ """
+ code = tenant.code
+ domain = tenant.domain
+ nc_url = f"http://nc-{code}:80" if is_active else f"http://nc-{code}-test:80"
+
+ lines = ["http:", " routers:"]
+
+ if tenant.is_manager:
+ lines += [
+ f" {code}-admin:",
+ f' rule: "Host(`{domain}`) && PathPrefix(`/admin`)"',
+ f" service: {code}-admin",
+ " entryPoints: [websecure]",
+ " tls:",
+ " certResolver: letsencrypt",
+ " priority: 200",
+ "",
+ ]
+
+ lines += [
+ f" {code}-drive:",
+ f' rule: "Host(`{domain}`)"',
+ f" service: {code}-drive",
+ " entryPoints: [websecure]",
+ " tls:",
+ " certResolver: letsencrypt",
+ "",
+ f" {code}-http:",
+ f' rule: "Host(`{domain}`)"',
+ " entryPoints: [web]",
+ " middlewares: [redirect-https]",
+ f" service: {code}-drive",
+ "",
+ " services:",
+ f" {code}-drive:",
+ " loadBalancer:",
+ " servers:",
+ f' - url: "{nc_url}"',
+ ]
+
+ if tenant.is_manager:
+ lines += [
+ f" {code}-admin:",
+ " loadBalancer:",
+ " servers:",
+ ' - url: "http://10.1.0.245:10280"',
+ ]
+
+ return "\n".join(lines) + "\n"
+
+
+def _ensure_traefik_routes(tenant, is_active: bool) -> bool:
+ """
+ 確保租戶 Traefik 路由檔案存在且內容正確。
+ 使用 SFTP 寫入 /home/porsche/traefik/dynamic/{code}.yml,Traefik 熱重載。
+ 回傳 True=路由已生效, False=失敗
+ """
+ import time
+ try:
+ import paramiko
+ code = tenant.code
+ file_path = f"{TRAEFIK_DYNAMIC_DIR}/{code}.yml"
+ expected = _generate_tenant_route_yaml(tenant, is_active)
+
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(settings.DOCKER_SSH_HOST, username=settings.DOCKER_SSH_USER, timeout=15)
+
+ sftp = client.open_sftp()
+ needs_write = True
+ try:
+ with sftp.open(file_path, "r") as f:
+ existing = f.read().decode()
+ if existing == expected:
+ needs_write = False
+ logger.info(f"Traefik route {code}.yml: already correct")
+ except FileNotFoundError:
+ logger.info(f"Traefik route {code}.yml: not found, creating")
+
+ if needs_write:
+ with sftp.open(file_path, "w") as f:
+ f.write(expected)
+ logger.info(f"Traefik route {code}.yml: written")
+
+ sftp.close()
+ client.close()
+
+ # 驗證 Traefik 已載入路由(最多等待 4 秒)
+ route_name = f"{code}-drive@file"
+ for attempt in range(2):
+ try:
+ resp = httpx.get(
+ f"{TRAEFIK_API_URL}/api/http/routers/{route_name}",
+ timeout=5.0,
+ )
+ if resp.status_code == 200 and resp.json().get("status") == "enabled":
+ logger.info(f"Traefik route {route_name}: enabled ✓")
+ return True
+ except Exception:
+ pass
+ if attempt == 0:
+ time.sleep(2)
+
+ logger.warning(f"Traefik route {route_name}: not visible in API after write")
+ return False
+
+ except Exception as e:
+ logger.error(f"_ensure_traefik_routes {tenant.code}: {e}")
+ return False
+
+
+# ─── Keycloak helpers ────────────────────────────────────────────────────────
+
+def _check_kc_realm(host: str, realm: str) -> Optional[bool]:
+ """
+ None = realm 不存在(未設定)
+ True = realm 存在且可連線
+ False = 連線失敗
+ """
+ try:
+ resp = httpx.get(
+ f"https://{host}/realms/{realm}/.well-known/openid-configuration",
+ timeout=5.0,
+ )
+ if resp.status_code == 200:
+ return True
+ if resp.status_code == 404:
+ return None
+ return False
+ except Exception as e:
+ logger.warning(f"KC realm check failed {host}/{realm}: {e}")
+ return False
+
+
+def _create_kc_realm(realm: str, tenant_name: str):
+ from app.services.keycloak_client import get_keycloak_client
+ kc = get_keycloak_client()
+ kc.create_realm(realm, tenant_name)
+ kc.update_realm_token_settings(realm, access_code_lifespan=600)
+
+
+def _ensure_kc_drive_client(realm: str, domain: str) -> Optional[str]:
+ """
+ 確保 Keycloak realm 中存在 'drive' confidential client(NC OIDC 用)。
+ 回傳 client_secret,若失敗回傳 None。
+ """
+ try:
+ from app.services.keycloak_client import get_keycloak_client
+ kc = get_keycloak_client()
+ # redirectUri 為根路徑(NC 直接服務租戶網域,無路徑前綴)
+ status = kc.create_confidential_client(
+ realm, "drive", [f"https://{domain}/*"]
+ )
+ if status in ("exists", "created"):
+ return kc.get_client_secret(realm, "drive")
+ logger.error(f"Failed to ensure drive client in realm {realm}: {status}")
+ return None
+ except Exception as e:
+ logger.error(f"_ensure_kc_drive_client {realm}: {e}")
+ return None
+
+
+def _nc_db_check(container_name: str, pg_host: str, pg_db: str, nc_domain: str) -> bool:
+ """
+ 驗證 NC 是否正確安裝並使用 PostgreSQL。
+ 若偵測到 SQLite(常見於 volumes 持久但重新部署的情況),自動修復:
+ 1. 刪除 config.php
+ 2. 清空 PostgreSQL DB schema
+ 3. 清除 data 目錄中的 SQLite 檔案
+ 4. 重新執行 occ maintenance:install 使用 pgsql
+ 5. 設定 overwritehost
+ 回傳 True=已正確使用 pgsql, False=仍有問題
+ """
+ try:
+ import paramiko, json as _json, psycopg2
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(settings.DOCKER_SSH_HOST, username=settings.DOCKER_SSH_USER, timeout=15)
+
+ # 取得 NC 安裝狀態
+ _, stdout, _ = client.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ status --output=json 2>/dev/null"
+ )
+ status_raw = stdout.read().decode().strip()
+
+ installed = False
+ try:
+ installed = _json.loads(status_raw).get("installed", False)
+ except Exception:
+ pass
+
+ if not installed:
+ logger.info(f"NC {container_name}: not installed yet, installing with pgsql...")
+ # 重置 PostgreSQL DB schema(確保乾淨狀態)
+ conn = psycopg2.connect(
+ host=pg_host, port=PG_PORT, dbname=pg_db,
+ user=PG_USER, password=PG_PASS, connect_timeout=5,
+ )
+ conn.autocommit = True
+ cur = conn.cursor()
+ cur.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO PUBLIC;")
+ conn.close()
+ logger.info(f"PostgreSQL {pg_db}@{pg_host}: schema reset for fresh install")
+
+ install_cmd = (
+ f"docker exec -u www-data {container_name} php /var/www/html/occ maintenance:install "
+ f"-n --admin-user admin --admin-pass NC1qaz2wsx "
+ f"--database pgsql --database-name '{pg_db}' "
+ f"--database-user {PG_USER} --database-pass {PG_PASS} "
+ f"--database-host '{pg_host}:{PG_PORT}' 2>&1"
+ )
+ _, stdout_inst, _ = client.exec_command(install_cmd)
+ stdout_inst.channel.settimeout(120)
+ try:
+ install_out = stdout_inst.read().decode().strip()
+ except Exception:
+ install_out = ""
+ logger.info(f"NC fresh install output: {install_out}")
+
+ # 設定 overwritehost
+ for cfg_cmd in [
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set overwritehost --value={nc_domain}",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set overwrite.cli.url --value=https://{nc_domain}",
+ ]:
+ _, out_cfg, _ = client.exec_command(cfg_cmd)
+ out_cfg.channel.settimeout(30)
+ try:
+ out_cfg.read()
+ except Exception:
+ pass
+
+ client.close()
+ success = "successfully installed" in install_out
+ if success:
+ logger.info(f"NC {container_name}: installed with pgsql ✓")
+ else:
+ logger.error(f"NC {container_name}: fresh install failed: {install_out}")
+ return success
+
+ # 檢查 dbtype
+ _, stdout2, _ = client.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ config:system:get dbtype 2>/dev/null"
+ )
+ dbtype = stdout2.read().decode().strip()
+ client.close()
+
+ if dbtype == "pgsql":
+ logger.info(f"NC {container_name}: already using pgsql ✓")
+ return True
+
+ # ─── 偵測到 SQLite,自動修復 ───────────────────────────────────────
+ logger.warning(f"NC {container_name}: dbtype={dbtype}, fixing to pgsql...")
+
+ # 1. 刪除 config.php 和 SQLite 殘留資料
+ client2 = paramiko.SSHClient()
+ client2.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client2.connect(settings.DOCKER_SSH_HOST, username=settings.DOCKER_SSH_USER, timeout=15)
+ for cmd in [
+ f"docker exec {container_name} rm -f /var/www/html/config/config.php",
+ f"docker exec {container_name} sh -c "
+ f"'rm -rf /var/www/html/data/admin /var/www/html/data/appdata_* "
+ f"/var/www/html/data/*.db /var/www/html/data/nextcloud.log'",
+ ]:
+ _, out, _ = client2.exec_command(cmd)
+ out.channel.recv_exit_status()
+ logger.info(f"Cleanup: {cmd}")
+
+ # 2. 重置 PostgreSQL DB schema
+ conn = psycopg2.connect(
+ host=pg_host, port=PG_PORT, dbname=pg_db,
+ user=PG_USER, password=PG_PASS, connect_timeout=5,
+ )
+ conn.autocommit = True
+ cur = conn.cursor()
+ cur.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO PUBLIC;")
+ conn.close()
+ logger.info(f"PostgreSQL {pg_db}@{pg_host}: schema reset")
+
+ # 3. 重新安裝 NC 使用 PostgreSQL
+ install_cmd = (
+ f"docker exec -u www-data {container_name} php /var/www/html/occ maintenance:install "
+ f"-n --admin-user admin --admin-pass NC1qaz2wsx "
+ f"--database pgsql --database-name '{pg_db}' "
+ f"--database-user {PG_USER} --database-pass {PG_PASS} "
+ f"--database-host '{pg_host}:{PG_PORT}' 2>&1"
+ )
+ _, stdout3, _ = client2.exec_command(install_cmd)
+ stdout3.channel.recv_exit_status()
+ install_out = stdout3.read().decode().strip()
+ logger.info(f"NC reinstall output: {install_out}")
+
+ # 4. 設定 overwritehost
+ for cfg_cmd in [
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set overwritehost --value={nc_domain}",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set overwrite.cli.url --value=https://{nc_domain}",
+ ]:
+ _, out4, _ = client2.exec_command(cfg_cmd)
+ out4.channel.recv_exit_status()
+
+ client2.close()
+ success = "successfully installed" in install_out
+ if success:
+ logger.info(f"NC {container_name}: fixed to pgsql ✓")
+ else:
+ logger.error(f"NC {container_name}: reinstall failed: {install_out}")
+ return success
+
+ except Exception as e:
+ logger.error(f"_nc_db_check {container_name}: {e}")
+ return False
+
+
+def _nc_initialized(container_name: str) -> bool:
+ """
+ 以 force_language == zh_TW 作為 NC 已完成初始化的判斷依據。
+ """
+ try:
+ import paramiko
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(settings.DOCKER_SSH_HOST, username=settings.DOCKER_SSH_USER, timeout=15)
+ _, stdout, _ = client.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ config:system:get force_language 2>/dev/null"
+ )
+ result = stdout.read().decode().strip()
+ client.close()
+ return result == "zh_TW"
+ except Exception as e:
+ logger.warning(f"NC initialized check failed {container_name}: {e}")
+ return False
+
+
+def _nc_initialize(
+ container_name: str, kc_host: str, realm: str, client_secret: str, nc_domain: str,
+ oo_container: str, oo_url: str, is_active: bool = False,
+) -> bool:
+ """
+ NC 容器首次完整初始化:
+ Init-1: 語言設定(zh_TW)
+ Init-2: 安裝必要 Apps(contacts / calendar / mail / onlyoffice)
+ Init-3: OIDC Provider 設定(呼叫 _configure_nc_oidc)
+ Init-4: SSO 強制模式(allow_multiple_user_backends=0)
+ Init-5: OnlyOffice 整合設定
+ ⚠️ Init-4 必須在 Init-3 之後執行,否則無法登入
+ """
+ try:
+ import paramiko
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(settings.DOCKER_SSH_HOST, username=settings.DOCKER_SSH_USER, timeout=15)
+
+ redis_host = REDIS_HOST_ACTIVE if is_active else REDIS_HOST_TRIAL
+
+ # Init-1: 語言設定
+ for cfg_key, cfg_val in [
+ ("default_language", "zh_TW"),
+ ("default_locale", "zh_TW"),
+ ("force_language", "zh_TW"),
+ ("force_locale", "zh_TW"),
+ ]:
+ _, out, _ = client.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ config:system:set {cfg_key} --value={cfg_val} 2>&1"
+ )
+ out.channel.recv_exit_status()
+
+ # Init-1b: Redis + APCu memcache(OIDC session 持久化必須)
+ for cfg_cmd in [
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set memcache.local --value='\\OC\\Memcache\\APCu' 2>&1",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set memcache.distributed --value='\\OC\\Memcache\\Redis' 2>&1",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set memcache.locking --value='\\OC\\Memcache\\Redis' 2>&1",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set redis host --value={redis_host} 2>&1",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set redis port --value={REDIS_PORT} --type=integer 2>&1",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set redis password --value={REDIS_PASS} 2>&1",
+ ]:
+ _, out, _ = client.exec_command(cfg_cmd)
+ out.channel.recv_exit_status()
+ logger.info(f"NC {container_name}: Redis memcache configured (host={redis_host})")
+
+ # Init-2: 安裝必要 Apps(已安裝時不影響結果)
+ for app in ["contacts", "calendar", "mail", "onlyoffice"]:
+ _, out, _ = client.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ app:install {app} 2>&1"
+ )
+ out.channel.recv_exit_status()
+ text = out.read().decode().strip()
+ logger.info(f"NC {container_name} app:install {app}: {text}")
+
+ client.close()
+
+ # Init-3: OIDC Provider 設定(複用現有函式)
+ _configure_nc_oidc(container_name, kc_host, realm, client_secret, nc_domain)
+
+ # Init-4: 強制 SSO(禁用本地登入)+ Init-5: OnlyOffice 整合設定
+ client2 = paramiko.SSHClient()
+ client2.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client2.connect(settings.DOCKER_SSH_HOST, username=settings.DOCKER_SSH_USER, timeout=15)
+
+ _, out, _ = client2.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ config:app:set user_oidc allow_multiple_user_backends --value=0 2>&1"
+ )
+ out.channel.recv_exit_status()
+
+ # Init-5: OnlyOffice 整合設定
+ from app.core.config import settings as _cfg
+ for oo_key, oo_val in [
+ ("DocumentServerUrl", f"{oo_url}/"),
+ ("DocumentServerInternalUrl", f"http://{oo_container}/"),
+ ("StorageUrl", f"https://{nc_domain}/"),
+ ("jwt_secret", _cfg.OO_JWT_SECRET),
+ ]:
+ _, out, _ = client2.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ config:app:set onlyoffice {oo_key} --value='{oo_val}' 2>&1"
+ )
+ out.channel.recv_exit_status()
+
+ client2.close()
+
+ logger.info(f"NC {container_name}: initialization complete ✓")
+ return True
+ except Exception as e:
+ logger.error(f"NC initialization failed {container_name}: {e}")
+ return False
+
+
+def _nc_oidc_configured(container_name: str) -> bool:
+ """檢查 NC 容器是否已設定 OIDC provider(user_oidc app 已安裝且有 provider)"""
+ try:
+ import paramiko
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(
+ settings.DOCKER_SSH_HOST,
+ username=settings.DOCKER_SSH_USER,
+ timeout=15,
+ )
+ _, stdout, _ = client.exec_command(
+ f"docker exec -u www-data {container_name} "
+ f"php /var/www/html/occ user_oidc:providers 2>/dev/null | grep -q clientId && echo yes || echo no"
+ )
+ result = stdout.read().decode().strip()
+ client.close()
+ return result == "yes"
+ except Exception as e:
+ logger.warning(f"NC OIDC check failed for {container_name}: {e}")
+ return False
+
+
+def _configure_nc_oidc(
+ container_name: str, kc_host: str, realm: str, client_secret: str, nc_domain: str
+) -> bool:
+ """
+ 設定 NC OIDC provider(使用 provider name='drive',對應 Keycloak client_id=drive)。
+ 同時設定 overwritehost 確保 OIDC callback URL 正確。
+ """
+ try:
+ import paramiko
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(
+ settings.DOCKER_SSH_HOST,
+ username=settings.DOCKER_SSH_USER,
+ timeout=15,
+ )
+ discovery = f"https://{kc_host}/realms/{realm}/.well-known/openid-configuration"
+
+ # 設定 overwritehost(OIDC callback 必須)
+ for cfg_cmd in [
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set overwritehost --value={nc_domain}",
+ f"docker exec -u www-data {container_name} php /var/www/html/occ config:system:set overwrite.cli.url --value=https://{nc_domain}",
+ ]:
+ _, out, _ = client.exec_command(cfg_cmd)
+ out.channel.recv_exit_status()
+
+ # 啟用 user_oidc app
+ _, out2, _ = client.exec_command(
+ f"docker exec -u www-data {container_name} php /var/www/html/occ app:enable user_oidc 2>&1"
+ )
+ out2.channel.recv_exit_status()
+
+ # 設定 OIDC provider(name=drive,與 Keycloak client_id 一致)
+ oidc_cmd = (
+ f"docker exec -u www-data {container_name} php /var/www/html/occ user_oidc:provider drive "
+ f"--clientid=drive "
+ f"--clientsecret={client_secret} "
+ f"--discoveryuri={discovery} "
+ f"--mapping-uid=preferred_username "
+ f"--mapping-display-name=name "
+ f"--mapping-email=email "
+ f"--unique-uid=0 "
+ f"--check-bearer=0 --send-id-token-hint=1 2>&1"
+ )
+ _, stdout, _ = client.exec_command(oidc_cmd)
+ stdout.channel.recv_exit_status()
+ out_text = stdout.read().decode().strip()
+ client.close()
+ logger.info(f"NC OIDC configure output: {out_text}")
+ return True
+ except Exception as e:
+ logger.error(f"NC OIDC configure failed for {container_name}: {e}")
+ return False
+
+
+# ─── PostgreSQL helpers ───────────────────────────────────────────────────────
+
+def _ensure_nc_db(host: str, dbname: str) -> bool:
+ """
+ 確保 NC 用的 PostgreSQL DB 存在,並授予 public schema 建表權限給所有用戶。
+ NC Docker 會自動建立 oc_admin* 用戶,需預先開放 public schema CREATE 權限。
+ """
+ try:
+ import psycopg2
+ # Connect to postgres DB to create NC DB
+ conn = psycopg2.connect(
+ host=host, port=PG_PORT, dbname="postgres",
+ user=PG_USER, password=PG_PASS,
+ connect_timeout=5,
+ )
+ conn.autocommit = True
+ cur = conn.cursor()
+ cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (dbname,))
+ if not cur.fetchone():
+ cur.execute(f'CREATE DATABASE "{dbname}" OWNER {PG_USER}')
+ logger.info(f"Created database {dbname}@{host}")
+ conn.close()
+
+ # Grant CREATE on public schema to all users (for NC oc_admin* users)
+ conn2 = psycopg2.connect(
+ host=host, port=PG_PORT, dbname=dbname,
+ user=PG_USER, password=PG_PASS,
+ connect_timeout=5,
+ )
+ conn2.autocommit = True
+ cur2 = conn2.cursor()
+ cur2.execute("GRANT ALL ON SCHEMA public TO PUBLIC")
+ conn2.close()
+ return True
+ except Exception as e:
+ logger.error(f"_ensure_nc_db {dbname}@{host}: {e}")
+ return False
+
+
+def _get_pg_db_size_gb(host: str, dbname: str) -> Optional[float]:
+ try:
+ import psycopg2
+ conn = psycopg2.connect(
+ host=host, port=PG_PORT, dbname=dbname,
+ user=PG_USER, password=PG_PASS,
+ connect_timeout=5,
+ )
+ cur = conn.cursor()
+ cur.execute("SELECT pg_database_size(%s)", (dbname,))
+ size_bytes = cur.fetchone()[0]
+ cur.close()
+ conn.close()
+ return round(size_bytes / (1024 ** 3), 3)
+ except Exception as e:
+ logger.warning(f"PG size check failed {dbname}@{host}: {e}")
+ return None
+
+
+# ─── Email notification ───────────────────────────────────────────────────────
+
+def _get_admin_emails(db: Session) -> list[str]:
+ """取得所有管理員租戶下所有啟用帳號的 notification_email"""
+ from app.models.account import Account
+ rows = (
+ db.query(Account.email)
+ .join(Tenant, Account.tenant_id == Tenant.id)
+ .filter(
+ Tenant.is_manager == True,
+ Account.is_active == True,
+ Account.email != None,
+ Account.email != "",
+ )
+ .all()
+ )
+ return list({r.email for r in rows})
+
+
+def _send_failure_alert(
+ tenant_code: str,
+ tenant_name: str,
+ domain: str,
+ failed_items: list[str],
+ admin_emails: list[str],
+):
+ """任何檢查項目 False 時,統一發送告警 email 給所有管理員"""
+ if not admin_emails:
+ logger.warning(f"No admin emails for failure alert on {tenant_code}")
+ return
+ try:
+ item_lines = "\n".join(f" ✗ {item}" for item in failed_items)
+ body = (
+ f"【Virtual MIS 告警】租戶服務異常\n\n"
+ f"租戶代碼 : {tenant_code}\n"
+ f"租戶名稱 : {tenant_name}\n"
+ f"網域 : {domain}\n"
+ f"時間 : {now_tw().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
+ f"異常項目:\n{item_lines}\n\n"
+ f"請盡速登入 Virtual MIS 後台確認並處理。"
+ )
+ msg = MIMEText(body, "plain", "utf-8")
+ msg["Subject"] = f"[VirtualMIS] 服務異常告警 — {tenant_code} ({domain})"
+ msg["From"] = f"vmis-alert@{settings.MAIL_MX_HOST}"
+ msg["To"] = ", ".join(admin_emails)
+
+ with smtplib.SMTP(settings.MAIL_MX_HOST, 25, timeout=10) as smtp:
+ smtp.sendmail(msg["From"], admin_emails, msg.as_string())
+ logger.info(f"Failure alert sent to {admin_emails} for {tenant_code}: {failed_items}")
+ except Exception as e:
+ logger.error(f"Failed to send failure alert for {tenant_code}: {e}")
+
+
+# ─── Main check ──────────────────────────────────────────────────────────────
def run_tenant_check(schedule_log_id: int, db: Session):
- from app.services.keycloak_client import KeycloakClient
from app.services.mail_client import MailClient
from app.services.docker_client import DockerClient
- from app.services.nextcloud_client import NextcloudClient
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
- kc = KeycloakClient()
mail = MailClient()
docker = DockerClient()
+ admin_emails = _get_admin_emails(db)
for tenant in tenants:
realm = tenant.keycloak_realm or tenant.code
+ is_active = tenant.status == "active"
+
+ nc_name = f"nc-{tenant.code}" if is_active else f"nc-{tenant.code}-test"
+ oo_name = f"oo-{tenant.code}" if is_active else f"oo-{tenant.code}-test"
+ kc_host = KC_HOST_ACTIVE if is_active else KC_HOST_TRIAL
+ pg_host = PG_HOST_ACTIVE if is_active else PG_HOST_TRIAL
+
result = TenantScheduleResult(
schedule_log_id=schedule_log_id,
tenant_id=tenant.id,
- recorded_at=datetime.utcnow(),
+ recorded_at=now_tw(),
)
fail_reasons = []
- # [1] Traefik
+ # ── [1] Traefik 路由檔案確認 ─────────────────────────────────────────
+ # 使用 file provider,scheduler 直接寫入 /home/porsche/traefik/dynamic/{code}.yml
try:
- result.traefik_status = docker.check_traefik_route(tenant.domain)
- result.traefik_done_at = datetime.utcnow()
+ ok = _ensure_traefik_routes(tenant, is_active)
+ result.traefik_status = ok
+ if not ok:
+ fail_reasons.append("traefik: route not loaded after write")
+ result.traefik_done_at = now_tw()
except Exception as e:
result.traefik_status = False
- result.traefik_done_at = datetime.utcnow()
+ result.traefik_done_at = now_tw()
fail_reasons.append(f"traefik: {e}")
- # [2] SSO
+
+ # ── [2] SSO (Keycloak realm + drive client) ──────────────────────────
+ kc_drive_secret: Optional[str] = None
try:
- exists = kc.realm_exists(realm)
- if not exists:
- kc.create_realm(realm, tenant.name)
- result.sso_result = True
- result.sso_done_at = datetime.utcnow()
+ sso_state = _check_kc_realm(kc_host, realm)
+ if sso_state is None:
+ # Realm 不存在 → 建立 realm + drive client
+ try:
+ _create_kc_realm(realm, tenant.name)
+ kc_drive_secret = _ensure_kc_drive_client(realm, tenant.domain)
+ result.sso_result = True if kc_drive_secret else False
+ if not kc_drive_secret:
+ fail_reasons.append("sso: realm created but drive client failed")
+ except Exception as ce:
+ result.sso_result = False
+ fail_reasons.append(f"sso create: {ce}")
+ elif sso_state is True:
+ # Realm 存在 → 確保 drive client 存在,並確保 token 逾時設定
+ kc_drive_secret = _ensure_kc_drive_client(realm, tenant.domain)
+ result.sso_result = True
+ if not kc_drive_secret:
+ fail_reasons.append("sso: drive client missing/failed")
+ result.sso_result = False
+ else:
+ try:
+ from app.services.keycloak_client import get_keycloak_client
+ get_keycloak_client().update_realm_token_settings(realm, access_code_lifespan=600)
+ except Exception:
+ pass
+ else:
+ result.sso_result = False
+ fail_reasons.append("sso: realm unreachable")
+ result.sso_done_at = now_tw()
except Exception as e:
result.sso_result = False
- result.sso_done_at = datetime.utcnow()
+ result.sso_done_at = now_tw()
fail_reasons.append(f"sso: {e}")
- # [3] Mailbox Domain (with DNS check for active tenants)
+ # ── [3] Mailbox domain ───────────────────────────────────────────────
try:
- if tenant.status == "active":
- dns_ok = mail.check_mx_dns(tenant.domain)
- if not dns_ok:
- result.mailbox_result = False
- result.mailbox_done_at = datetime.utcnow()
- fail_reasons.append("mailbox: MX record not configured")
- db.add(result)
- db.commit()
- continue
- domain_exists = mail.domain_exists(tenant.domain)
- if not domain_exists:
- mail.create_domain(tenant.domain)
- result.mailbox_result = True
- result.mailbox_done_at = datetime.utcnow()
+ if mail.domain_exists(tenant.domain):
+ result.mailbox_result = True
+ else:
+ # Domain 未設定 → 建立
+ ok = mail.create_domain(tenant.domain)
+ result.mailbox_result = True if ok else False
+ if not ok:
+ fail_reasons.append("mailbox: create domain failed")
+ result.mailbox_done_at = now_tw()
except Exception as e:
result.mailbox_result = False
- result.mailbox_done_at = datetime.utcnow()
+ result.mailbox_done_at = now_tw()
fail_reasons.append(f"mailbox: {e}")
- # [4] Nextcloud container
+ # ── [4] NC container + DB 驗證 + OIDC 設定 ─────────────────────────
+ pg_db = f"nc_{tenant.code}_db"
try:
- nc_name = f"nc-{realm}"
- result.nc_result = docker.ensure_container_running(nc_name, tenant.code, realm)
- result.nc_done_at = datetime.utcnow()
+ nc_state = docker.check_container_ssh(nc_name)
+ if nc_state is None:
+ # 容器不存在 → 確保 docker-compose.yml + DB + 部署
+ logger.info(f"NC {nc_name}: not found, ensuring compose/DB and deploying")
+ _ensure_tenant_compose(tenant, is_active)
+ _ensure_nc_db(pg_host, pg_db)
+ ok = docker.ssh_compose_up(tenant.code)
+ result.nc_result = True if ok else False
+ if not ok:
+ fail_reasons.append("nc: deploy failed")
+ else:
+ # 部署成功後驗證 NC 是否正確使用 PostgreSQL
+ if not _nc_db_check(nc_name, pg_host, pg_db, tenant.domain):
+ result.nc_result = False
+ fail_reasons.append("nc: installed but not using pgsql")
+ elif nc_state is False:
+ # 容器存在但已停止 → 重啟
+ logger.info(f"NC {nc_name}: stopped, restarting")
+ ok = docker.ssh_compose_up(tenant.code)
+ result.nc_result = True if ok else False
+ if not ok:
+ fail_reasons.append("nc: start failed")
+ else:
+ # 容器正常運行 → 驗證 DB 類型(防止 sqlite3 殘留問題)
+ db_ok = _nc_db_check(nc_name, pg_host, pg_db, tenant.domain)
+ if not db_ok:
+ result.nc_result = False
+ fail_reasons.append("nc: DB check failed (possible sqlite3 issue)")
+ else:
+ result.nc_result = True
+ if kc_drive_secret:
+ if not _nc_initialized(nc_name):
+ # 首次初始化:語言 + Apps + OIDC + SSO 強制模式 + OO 整合
+ oo_url = (f"https://office-{tenant.code}.ease.taipei" if is_active
+ else f"https://office-{tenant.code}.lab.taipei")
+ ok = _nc_initialize(nc_name, kc_host, realm, kc_drive_secret, tenant.domain,
+ oo_name, oo_url, is_active)
+ if not ok:
+ fail_reasons.append("nc: initialization failed")
+ else:
+ # 已初始化:僅同步 OIDC secret(確保與 KC 一致)
+ ok = _configure_nc_oidc(nc_name, kc_host, realm, kc_drive_secret, tenant.domain)
+ if not ok:
+ fail_reasons.append("nc: OIDC sync failed")
+ result.nc_done_at = now_tw()
except Exception as e:
result.nc_result = False
- result.nc_done_at = datetime.utcnow()
+ result.nc_done_at = now_tw()
fail_reasons.append(f"nc: {e}")
- # [5] OnlyOffice container
+ # ── [5] OO container ─────────────────────────────────────────────────
try:
- oo_name = f"oo-{realm}"
- result.office_result = docker.ensure_container_running(oo_name, tenant.code, realm)
- result.office_done_at = datetime.utcnow()
+ oo_state = docker.check_container_ssh(oo_name)
+ if oo_state is None:
+ ok = docker.ssh_compose_up(tenant.code)
+ result.office_result = True if ok else False
+ if not ok:
+ fail_reasons.append("oo: deploy failed")
+ elif oo_state is False:
+ ok = docker.ssh_compose_up(tenant.code)
+ result.office_result = True if ok else False
+ if not ok:
+ fail_reasons.append("oo: start failed")
+ else:
+ result.office_result = True
+ result.office_done_at = now_tw()
except Exception as e:
result.office_result = False
- result.office_done_at = datetime.utcnow()
- fail_reasons.append(f"office: {e}")
+ result.office_done_at = now_tw()
+ fail_reasons.append(f"oo: {e}")
- # [6] Quota
+ # ── [6] Quota (OO disk + PG DB size) ────────────────────────────────
try:
- nc = NextcloudClient(tenant.domain)
- result.quota_usage = nc.get_total_quota_used_gb()
+ oo_gb = docker.get_oo_disk_usage_gb(oo_name) or 0.0
+ pg_gb = _get_pg_db_size_gb(pg_host, pg_db) or 0.0
+ result.quota_usage = round(oo_gb + pg_gb, 3)
except Exception as e:
logger.warning(f"Quota check failed for {tenant.code}: {e}")
if fail_reasons:
result.fail_reason = "; ".join(fail_reasons)
+ # 任何項目 False → 統一發送告警給所有管理員
+ failed_items = []
+ if result.traefik_status is False:
+ failed_items.append("Traefik 路由")
+ if result.sso_result is False:
+ failed_items.append("SSO (Keycloak Realm)")
+ if result.mailbox_result is False:
+ failed_items.append("Mailbox Domain")
+ if result.nc_result is False:
+ failed_items.append("Nextcloud 容器")
+ if result.office_result is False:
+ failed_items.append("OnlyOffice 容器")
+ if failed_items:
+ _send_failure_alert(
+ tenant.code, tenant.name, tenant.domain, failed_items, admin_emails
+ )
+
db.add(result)
db.commit()
diff --git a/backend/app/services/scheduler/watchdog.py b/backend/app/services/scheduler/watchdog.py
index d201e1f..a2d6ffa 100644
--- a/backend/app/services/scheduler/watchdog.py
+++ b/backend/app/services/scheduler/watchdog.py
@@ -4,6 +4,7 @@ Watchdog: APScheduler BackgroundScheduler,每 3 分鐘掃描 schedules 表。
"""
import logging
from datetime import datetime
+from app.core.utils import now_tw
from apscheduler.schedulers.background import BackgroundScheduler
from croniter import croniter
from sqlalchemy import update
@@ -24,7 +25,7 @@ def _watchdog_tick():
db.query(Schedule)
.filter(
Schedule.status == "Waiting",
- Schedule.next_run_at <= datetime.utcnow(),
+ Schedule.next_run_at <= now_tw(),
)
.all()
)
@@ -44,7 +45,7 @@ def _watchdog_tick():
log = ScheduleLog(
schedule_id=schedule.id,
schedule_name=schedule.name,
- started_at=datetime.utcnow(),
+ started_at=now_tw(),
status="running",
)
db.add(log)
@@ -60,12 +61,12 @@ def _watchdog_tick():
final_status = "error"
# Update log
- log.ended_at = datetime.utcnow()
+ log.ended_at = now_tw()
log.status = final_status
- # Recalculate next_run_at
+ # Recalculate next_run_at (5-field cron: 分 時 日 月 週)
try:
- cron = croniter(schedule.cron_timer, datetime.utcnow())
+ cron = croniter(schedule.cron_timer, now_tw())
next_run = cron.get_next(datetime)
except Exception:
next_run = None
@@ -76,7 +77,7 @@ def _watchdog_tick():
.where(Schedule.id == schedule.id)
.values(
status="Waiting",
- last_run_at=datetime.utcnow(),
+ last_run_at=now_tw(),
next_run_at=next_run,
last_status=final_status,
)
diff --git a/backend/app/services/seed.py b/backend/app/services/seed.py
index c962844..8ad12bf 100644
--- a/backend/app/services/seed.py
+++ b/backend/app/services/seed.py
@@ -1,9 +1,11 @@
-"""Initial data seed: schedules + servers"""
+"""Initial data seed: schedules + servers + system settings"""
from datetime import datetime
+from app.core.utils import now_tw, configure_timezone
from croniter import croniter
from sqlalchemy.orm import Session
from app.models.schedule import Schedule
from app.models.server import Server
+from app.models.settings import SystemSettings
INITIAL_SCHEDULES = [
@@ -26,7 +28,7 @@ INITIAL_SERVERS = [
def _calc_next_run(cron_timer: str) -> datetime:
# croniter: six-field cron (sec min hour day month weekday)
- cron = croniter(cron_timer, datetime.utcnow())
+ cron = croniter(cron_timer, now_tw())
return cron.get_next(datetime)
@@ -46,4 +48,13 @@ def seed_initial_data(db: Session) -> None:
if not db.get(Server, sv["id"]):
db.add(Server(**sv))
+ # Seed default system settings (id=1)
+ if not db.get(SystemSettings, 1):
+ db.add(SystemSettings(id=1))
+
db.commit()
+
+ # Apply timezone from settings
+ s = db.get(SystemSettings, 1)
+ if s:
+ configure_timezone(s.timezone)
diff --git a/docker/radicale/README.md b/docker/radicale/README.md
new file mode 100644
index 0000000..158d356
--- /dev/null
+++ b/docker/radicale/README.md
@@ -0,0 +1,249 @@
+# Radicale CalDAV/CardDAV Server
+
+Virtual MIS 日曆與聯絡人服務後端。
+
+## 服務資訊
+
+- **服務名稱**: Radicale CalDAV/CardDAV Server
+- **容器名稱**: vmis-radicale
+- **內部埠號**: 5232
+- **外部埠號**: 5232 (開發環境)
+- **認證方式**: HTTP Header (X-Remote-User) - 由 Keycloak OAuth2 整合
+- **資料儲存**: File System (/data/collections)
+
+## 功能
+
+- ✅ CalDAV - 日曆同步 (RFC 4791)
+- ✅ CardDAV - 聯絡人同步 (RFC 6352)
+- ✅ HTTP Header 認證
+- ✅ 多使用者支援
+- ✅ Web 管理介面
+
+## 目錄結構
+
+```
+radicale/
+├── config/
+│ └── config # Radicale 配置檔
+├── data/ # 資料目錄(自動建立)
+│ └── collections/ # 日曆與聯絡人資料
+├── docker-compose.yml # Docker Compose 配置
+└── README.md # 本文件
+```
+
+## 部署步驟
+
+### 1. 建立資料目錄
+
+```bash
+mkdir -p data/collections
+```
+
+### 2. 設定權限
+
+```bash
+# 確保資料目錄權限正確(UID=1000, GID=1000)
+sudo chown -R 1000:1000 data/
+chmod -R 750 data/
+```
+
+### 3. 啟動服務
+
+```bash
+# 啟動
+docker-compose up -d
+
+# 查看日誌
+docker-compose logs -f
+
+# 停止
+docker-compose down
+```
+
+### 4. 驗證服務
+
+```bash
+# 檢查服務狀態
+docker-compose ps
+
+# 測試連線
+curl -I http://localhost:5232
+
+# 預期回應: HTTP/1.1 200 OK
+```
+
+## 整合架構
+
+### Virtual MIS 整合
+
+```
+Virtual MIS Backend
+ ↓ caldav_service.py
+Radicale (CalDAV/CardDAV)
+ ↓ File System
+/data/collections/
+ ├── {username}/
+ │ ├── {calendar-uuid}/ # 日曆
+ │ └── {addressbook-uuid}/ # 通訊錄
+```
+
+### 認證流程
+
+```
+1. 使用者登入 Keycloak SSO
+2. Virtual MIS Backend 取得 username
+3. Backend 使用 X-Remote-User Header 呼叫 Radicale
+4. Radicale 信任 Header,直接存取對應使用者資料
+```
+
+## 資料結構
+
+### 日曆資料 (CalDAV)
+
+```
+/data/collections/{username}/{calendar-uuid}/
+├── event-1.ics
+├── event-2.ics
+└── ...
+```
+
+### 聯絡人資料 (CardDAV)
+
+```
+/data/collections/{username}/{addressbook-uuid}/
+├── contact-1.vcf
+├── contact-2.vcf
+└── ...
+```
+
+## 客戶端配置
+
+### iOS / macOS
+
+**日曆 (CalDAV)**:
+```
+伺服器: 10.1.0.254:5232 (開發環境)
+使用者名稱: {Keycloak username}
+密碼: (由 Backend 處理)
+使用 SSL: 否 (開發環境) / 是 (正式環境)
+```
+
+**聯絡人 (CardDAV)**:
+```
+伺服器: 10.1.0.254:5232
+使用者名稱: {Keycloak username}
+密碼: (由 Backend 處理)
+使用 SSL: 否 (開發環境) / 是 (正式環境)
+```
+
+### Android (DAVx⁵)
+
+```
+基礎 URL: http://10.1.0.254:5232/
+使用者名稱: {Keycloak username}
+密碼: (由 Backend 處理)
+```
+
+### Thunderbird
+
+**日曆**:
+```
+位置: http://10.1.0.254:5232/{username}/{calendar-uuid}/
+```
+
+**聯絡人** (需要 CardBook 擴充套件):
+```
+URL: http://10.1.0.254:5232/{username}/{addressbook-uuid}/
+```
+
+## 監控與維護
+
+### 查看日誌
+
+```bash
+# 即時日誌
+docker-compose logs -f radicale
+
+# 最近 100 行
+docker-compose logs --tail=100 radicale
+```
+
+### 備份策略
+
+```bash
+#!/bin/bash
+# 備份腳本
+BACKUP_DIR="/backups/radicale"
+DATE=$(date +%Y%m%d_%H%M%S)
+
+# 備份資料
+tar -czf ${BACKUP_DIR}/radicale_data_${DATE}.tar.gz \
+ ./data/
+
+# 保留最近 30 天
+find ${BACKUP_DIR} -name "radicale_data_*.tar.gz" -mtime +30 -delete
+```
+
+### 效能監控
+
+```bash
+# 查看儲存空間
+du -sh data/
+
+# 查看容器資源使用
+docker stats vmis-radicale
+```
+
+## 故障排除
+
+### 問題 1: 無法連線
+
+**檢查**:
+```bash
+# 檢查容器狀態
+docker-compose ps
+
+# 檢查埠號占用
+netstat -tulpn | grep 5232
+
+# 檢查防火牆
+sudo ufw status
+```
+
+### 問題 2: 認證失敗
+
+**檢查**:
+- X-Remote-User Header 是否正確傳遞
+- Radicale 配置檔中 auth.type 是否為 http_x_remote_user
+- Backend 呼叫時是否正確設定 Header
+
+### 問題 3: 資料無法寫入
+
+**檢查**:
+```bash
+# 檢查目錄權限
+ls -la data/
+
+# 修正權限
+sudo chown -R 1000:1000 data/
+chmod -R 750 data/
+```
+
+## 安全建議
+
+1. **認證**: 使用 Keycloak SSO 統一認證
+2. **網路隔離**: 僅允許 Backend 存取 Radicale
+3. **HTTPS**: 正式環境必須使用 HTTPS
+4. **備份**: 定期備份 data/ 目錄
+5. **權限**: 嚴格控制檔案系統權限
+
+## 參考文件
+
+- [Radicale 官方文件](https://radicale.org/v3.html)
+- [CalDAV 協議 (RFC 4791)](https://tools.ietf.org/html/rfc4791)
+- [CardDAV 協議 (RFC 6352)](https://tools.ietf.org/html/rfc6352)
+
+## 更新記錄
+
+- **2026-03-02**: 建立 Radicale Docker Compose 配置
+- **2026-03-02**: 整合 Virtual MIS Calendar & Contacts
diff --git a/docker/radicale/config/config b/docker/radicale/config/config
new file mode 100644
index 0000000..49e5967
--- /dev/null
+++ b/docker/radicale/config/config
@@ -0,0 +1,34 @@
+[server]
+# 監聽所有介面
+hosts = 0.0.0.0:5232
+
+# 最大連線數
+max_connections = 100
+
+# 逾時設定
+timeout = 60
+
+[auth]
+# 使用 HTTP Header 認證 (由 Keycloak OAuth2 Proxy 提供)
+type = http_x_remote_user
+
+[storage]
+# 檔案系統儲存
+type = multifilesystem
+
+# 資料目錄
+filesystem_folder = /data/collections
+
+# Hook for changes (optional - for git versioning)
+# hook = git add -A && git commit -m "Changes by %(user)s"
+
+[web]
+# 啟用 Web 介面
+type = internal
+
+[logging]
+# 日誌等級
+level = info
+
+# 日誌格式
+# debug 模式可設為 debug
diff --git a/docker/radicale/docker-compose.yml b/docker/radicale/docker-compose.yml
new file mode 100644
index 0000000..66b8317
--- /dev/null
+++ b/docker/radicale/docker-compose.yml
@@ -0,0 +1,44 @@
+version: '3.8'
+
+services:
+ radicale:
+ image: tomsquest/docker-radicale:latest
+ container_name: vmis-radicale
+ restart: unless-stopped
+
+ # 環境變數
+ environment:
+ # 設定時區
+ - TZ=Asia/Taipei
+
+ # UID/GID (與 porsche 使用者相同)
+ - UID=1000
+ - GID=1000
+
+ # 資料卷
+ volumes:
+ # 配置檔
+ - ./config:/config:ro
+
+ # 資料目錄
+ - ./data:/data
+
+ # 埠號
+ ports:
+ - "5232:5232"
+
+ # 網路
+ networks:
+ - vmis-network
+
+ # 健康檢查
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:5232/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+
+networks:
+ vmis-network:
+ external: true
diff --git a/docs/WebMail_SSO_Integration_Guide.md b/docs/WebMail_SSO_Integration_Guide.md
new file mode 100644
index 0000000..49ddbf4
--- /dev/null
+++ b/docs/WebMail_SSO_Integration_Guide.md
@@ -0,0 +1,254 @@
+# WebMail Keycloak SSO 整合指南
+
+本指南說明如何將 Roundcube WebMail 與 Keycloak SSO 整合。
+
+## 前置條件
+
+已完成:
+- ✅ Keycloak Client 已建立
+- ✅ Client ID: `webmail`
+- ✅ Client Secret: `CDUcB68SZUEZ2zmiQV4cz22czjw6Sn1q`
+- ✅ Realm: `vmis-admin`
+
+## 步驟 1: 連接到 WebMail 主機
+
+```bash
+ssh 10.1.0.254
+```
+
+## 步驟 2: 查看 Roundcube 容器
+
+```bash
+docker ps | grep -i roundcube
+```
+
+記錄容器名稱 (例如: `roundcube` 或 `webmail`)
+
+## 步驟 3: 查看 Roundcube 配置掛載路徑
+
+```bash
+docker inspect <容器名稱> | grep -A 5 "Mounts"
+```
+
+找出配置檔案的掛載路徑 (通常是 `/var/www/html/config` 或類似路徑)
+
+## 步驟 4: 安裝 OAuth2 插件
+
+### 方法 A: 如果 Roundcube 使用官方 Docker 映像
+
+1. 進入容器:
+```bash
+docker exec -it <容器名稱> bash
+```
+
+2. 使用 Composer 安裝 OAuth2 插件:
+```bash
+cd /var/www/html
+composer require roundcube/oauth2
+```
+
+3. 啟用插件 (編輯 config/config.inc.php):
+```bash
+vi config/config.inc.php
+```
+
+找到 `$config['plugins']` 行,加入 `'oauth2'`:
+```php
+$config['plugins'] = array(
+ 'oauth2',
+ // ... 其他插件
+);
+```
+
+### 方法 B: 如果無法使用 Composer
+
+手動下載並安裝插件:
+
+```bash
+cd /var/www/html/plugins
+git clone https://github.com/roundcube/roundcubemail-oauth2.git oauth2
+```
+
+## 步驟 5: 配置 OAuth2 插件
+
+建立或編輯 `config/oauth2.inc.php`:
+
+```bash
+vi /var/www/html/config/oauth2.inc.php
+```
+
+加入以下內容:
+
+```php
+ 'generic',
+ 'client_id' => 'webmail',
+ 'client_secret' => 'CDUcB68SZUEZ2zmiQV4cz22czjw6Sn1q',
+ 'auth_uri' => 'https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/auth',
+ 'token_uri' => 'https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/token',
+ 'identity_uri' => 'https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/userinfo',
+ 'redirect_uri' => 'https://webmail.porscheworld.tw/index.php/login/oauth',
+ 'scope' => 'openid email profile',
+ 'login_redirect' => true,
+ 'username_field' => 'email',
+ 'identity_fields' => array(
+ 'email' => 'email',
+ 'name' => 'name',
+ 'username' => 'preferred_username'
+ ),
+);
+
+// 自動登入 (可選)
+$config['oauth2_auto_login'] = true;
+
+// 允許傳統登入 (開發階段建議保留)
+$config['oauth2_allow_traditional_login'] = true;
+```
+
+## 步驟 6: 修改 Roundcube 主配置
+
+編輯 `config/config.inc.php`:
+
+```bash
+vi /var/www/html/config/config.inc.php
+```
+
+確保以下設定:
+
+```php
+// 啟用插件
+$config['plugins'] = array(
+ 'oauth2',
+ // ... 其他插件
+);
+
+// IMAP 設定 (根據實際郵件伺服器調整)
+$config['default_host'] = 'ssl://10.1.0.254';
+$config['default_port'] = 993;
+
+// SMTP 設定
+$config['smtp_server'] = 'tls://10.1.0.254';
+$config['smtp_port'] = 587;
+
+// 使用者名稱網域 (根據 Keycloak 使用者的 email 設定)
+$config['username_domain'] = array(
+ 'porscheworld.tw' => 'porscheworld.tw',
+ 'lab.taipei' => 'lab.taipei',
+ 'ease.taipei' => 'ease.taipei',
+);
+
+// 自動完成郵件地址
+$config['mail_domain'] = 'lab.taipei';
+```
+
+## 步驟 7: 設定郵件地址對應規則
+
+Keycloak 使用者的 `email` 屬性應該對應到郵件伺服器的帳號。
+
+### 確保 Keycloak 使用者有正確的 email
+
+1. 登入 Keycloak Admin Console:
+```
+https://auth.lab.taipei/admin
+```
+
+2. 進入 `vmis-admin` Realm
+
+3. 檢查使用者的 Email 欄位,例如:
+ - Username: `sysadmin`
+ - Email: `admin@lab.taipei`
+
+這個 Email 必須是郵件伺服器上實際存在的帳號。
+
+## 步驟 8: 重啟 Roundcube 容器
+
+```bash
+docker restart <容器名稱>
+```
+
+## 步驟 9: 測試 SSO 登入
+
+1. 清除瀏覽器 Cookie
+
+2. 訪問 WebMail:
+```
+https://webmail.porscheworld.tw
+```
+
+3. 應該會自動重導向到 Keycloak 登入頁面:
+```
+https://auth.lab.taipei/realms/vmis-admin/protocol/openid-connect/auth?...
+```
+
+4. 使用 Keycloak 帳號登入 (例如: `sysadmin`)
+
+5. 登入成功後,應該會重導向回 WebMail 並自動登入郵件帳號
+
+## 故障排除
+
+### 問題 1: 無法重導向到 Keycloak
+
+**檢查:**
+- OAuth2 插件是否正確安裝
+- `config/oauth2.inc.php` 是否存在且設定正確
+- Roundcube 容器日誌: `docker logs <容器名稱>`
+
+### 問題 2: 登入後顯示「Invalid credentials」
+
+**可能原因:**
+- Keycloak 使用者的 email 與郵件伺服器帳號不符
+- 郵件伺服器密碼與 Keycloak 密碼不同
+
+**解決方案:**
+需要實作「密碼同步」或使用「IMAP OAuth2」:
+
+1. **方案 A: 密碼同步** - Keycloak 密碼變更時同步到郵件伺服器
+2. **方案 B: IMAP OAuth2** - 郵件伺服器支援 OAuth2 認證 (需要 Docker Mailserver 配置)
+
+### 問題 3: 重導向 URI 不符
+
+**錯誤訊息:**
+```
+Invalid redirect_uri
+```
+
+**解決方案:**
+檢查 Keycloak Client 的 Valid Redirect URIs 設定:
+```
+https://webmail.porscheworld.tw/*
+```
+
+## 進階設定
+
+### 整合 HR Portal 多帳號切換
+
+如果要實現「員工可以使用 SSO 登入後,切換不同的郵件帳號」:
+
+1. 安裝 `multi_accounts` 插件
+2. 配置 API 端點連接 HR Portal
+3. 從 HR Portal 取得員工授權的郵件帳號列表
+
+詳見: [郵件系統設計文件](P:\porscheworld\2.專案設計區\3.MailSystem\郵件系統設計文件.md)
+
+## 相關連結
+
+- Keycloak Admin: https://auth.lab.taipei/admin
+- WebMail: https://webmail.porscheworld.tw
+- Roundcube OAuth2 Plugin: https://github.com/roundcube/roundcubemail-oauth2
+
+## 完成檢查清單
+
+- [ ] OAuth2 插件已安裝
+- [ ] `config/oauth2.inc.php` 設定完成
+- [ ] `config/config.inc.php` 啟用插件
+- [ ] Keycloak 使用者 email 正確設定
+- [ ] Roundcube 容器已重啟
+- [ ] SSO 登入測試成功
+- [ ] 郵件收發測試成功
diff --git a/docs/architecture/01-系統架構設計.md b/docs/architecture/01-系統架構設計.md
new file mode 100644
index 0000000..f763452
--- /dev/null
+++ b/docs/architecture/01-系統架構設計.md
@@ -0,0 +1,225 @@
+# Virtual MIS - 系統架構設計
+
+**版本**: v1.0
+**日期**: 2026-02-27
+**狀態**: 規劃中
+
+---
+
+## 系統架構圖
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 客戶企業 │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ 員工 A │ │ 員工 B │ │ 員工 C │ │ 管理者 │ │
+│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
+└────────┼─────────────┼─────────────┼─────────────┼─────────┘
+ │ │ │ │
+ └─────────────┴─────────────┴─────────────┘
+ │
+ ┌─────────▼─────────┐
+ │ Virtual MIS │
+ │ 統一入口 (SSO) │
+ └─────────┬─────────┘
+ │
+ ┌─────────────────┼─────────────────┐
+ │ │ │
+ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐
+ │ HR │ │ Service │ │ Billing │
+ │ Portal │ │ Gateway │ │ System │
+ └────┬────┘ └────┬────┘ └─────────┘
+ │ │
+ │ ┌──────────┼──────────┐
+ │ │ │ │
+ ┌────▼────▼─┐ ┌────▼────┐ ┌──▼──────┐
+ │ Mail │ │Calendar │ │ Drive │
+ │ Service │ │Service │ │ Service │
+ └───────────┘ └─────────┘ └─────────┘
+ │
+ ┌────▼────┐
+ │ Office │
+ │ Service │
+ └─────────┘
+```
+
+## 核心模組
+
+### 1. 租戶開通系統 (Tenant Onboarding)
+
+**功能**:
+- 自動建立租戶資料
+- 網域配置與 DNS 設定
+- Keycloak Realm 建立
+- 服務初始化
+- 管理員帳號建立
+
+**API 端點**:
+```
+POST /api/v1/tenant-onboarding
+GET /api/v1/tenant-onboarding/{tenant_id}
+PUT /api/v1/tenant-onboarding/{tenant_id}
+DELETE /api/v1/tenant-onboarding/{tenant_id}
+```
+
+### 2. 服務整合閘道 (Service Integration Gateway)
+
+**功能**:
+- Mail System API 整合
+- Calendar System API 整合
+- Drive System API 整合
+- Office System API 整合
+- 服務健康檢查
+- 統一錯誤處理
+
+**API 端點**:
+```
+POST /api/v1/services/mail/create-account
+POST /api/v1/services/calendar/create-calendar
+POST /api/v1/services/drive/create-storage
+POST /api/v1/services/office/grant-access
+GET /api/v1/services/health
+```
+
+### 3. 計費管理系統 (Billing System)
+
+**功能**:
+- 訂閱方案管理
+- 使用量追蹤
+- 帳單生成
+- 金流整合 (綠界/藍新)
+- 自動續約/停權
+
+**API 端點**:
+```
+POST /api/v1/billing/subscribe
+GET /api/v1/billing/invoices
+POST /api/v1/billing/payment
+GET /api/v1/billing/usage/{tenant_id}
+```
+
+### 4. 管理後台 (Admin Portal)
+
+**功能**:
+- 租戶管理
+- 服務監控儀表板
+- 使用量報表
+- 客戶支援工單
+- 系統配置
+
+**頁面**:
+```
+/admin/dashboard - 總覽儀表板
+/admin/tenants - 租戶管理
+/admin/services - 服務狀態
+/admin/billing - 計費管理
+/admin/support - 客戶支援
+```
+
+### 5. Landing Page (行銷頁面)
+
+**功能**:
+- 服務介紹
+- 定價方案
+- 免費試用申請
+- 客戶見證
+- 聯絡表單
+
+**頁面**:
+```
+/ - 首頁
+/pricing - 定價方案
+/features - 功能介紹
+/trial - 免費試用
+/contact - 聯絡我們
+```
+
+## 資料庫設計
+
+### 核心表
+
+1. **tenants** - 租戶資料 (繼承 HR Portal)
+2. **subscriptions** - 訂閱記錄
+3. **invoices** - 帳單記錄
+4. **service_usage** - 服務使用記錄
+5. **support_tickets** - 客戶支援工單
+
+## 技術選型
+
+### 後端
+- **語言**: Python 3.11+
+- **框架**: FastAPI 0.115+
+- **ORM**: SQLAlchemy 2.0+
+- **驗證**: Pydantic v2
+- **任務隊列**: Celery + Redis
+
+### 前端
+- **框架**: Next.js 15 (App Router)
+- **UI 庫**: Tailwind CSS + shadcn/ui
+- **狀態管理**: React Hooks + Context
+- **表單**: React Hook Form + Zod
+
+### 基礎設施
+- **容器化**: Docker + Docker Compose
+- **反向代理**: Nginx
+- **SSL**: Let's Encrypt (Certbot)
+- **監控**: Prometheus + Grafana
+
+## 部署架構
+
+```
+Internet
+ │
+ ▼
+┌───────────────┐
+│ Nginx Proxy │ (SSL Termination)
+└───────┬───────┘
+ │
+ ┌───┴───┬───────┬───────┬───────┐
+ │ │ │ │ │
+ Admin Landing API HR Services
+ Portal Page Gateway Portal (Mail/Cal/Drive)
+```
+
+## 擴展性設計
+
+### 水平擴展
+- API Gateway 支援多實例
+- 使用 Redis 作為共享快取
+- 資料庫讀寫分離
+
+### 垂直擴展
+- 服務模組化設計
+- 微服務架構準備
+- 容器資源動態調整
+
+## 安全性設計
+
+1. **認證授權**:
+ - Keycloak SSO
+ - JWT Token
+ - RBAC 權限控制
+
+2. **資料安全**:
+ - 資料加密傳輸 (TLS)
+ - 敏感資料加密儲存
+ - 定期備份
+
+3. **網路安全**:
+ - CORS 配置
+ - Rate Limiting
+ - DDoS 防護
+
+## 監控與告警
+
+- **服務健康**: 每分鐘檢查
+- **使用量追蹤**: 即時記錄
+- **錯誤日誌**: 集中管理
+- **性能指標**: CPU/記憶體/網路
+
+---
+
+**下一步**:
+1. 詳細 API 規格設計
+2. 資料庫 Schema 設計
+3. 開發環境建置
diff --git a/docs/business/商業計畫.md b/docs/business/商業計畫.md
new file mode 100644
index 0000000..b2d06de
--- /dev/null
+++ b/docs/business/商業計畫.md
@@ -0,0 +1,213 @@
+# Virtual MIS - 商業計畫
+
+**版本**: v1.0
+**日期**: 2026-02-27
+
+---
+
+## 市場分析
+
+### 目標市場
+
+**主要客群**:
+1. 新創公司 (5-20人)
+ - 需要專業 IT 服務但沒有專職 IT 人員
+ - 預算有限,希望降低 IT 成本
+
+2. 中小企業 (20-50人)
+ - 現有 IT 資源不足
+ - 需要現代化的協作工具
+
+3. 遠距團隊
+ - 分散式辦公需求
+ - 需要統一的協作平台
+
+### 市場規模
+
+- 台灣中小企業數量: 約 159 萬家 (2024)
+- 潛在客戶 (10-100人企業): 約 50 萬家
+- 目標市場滲透率 (3年): 0.1% = 500 家
+- 預估年營收 (3年): NT$ 9,000,000
+
+## 競爭分析
+
+### 主要競爭對手
+
+| 服務商 | 優勢 | 劣勢 | 我們的差異化 |
+|--------|------|------|-------------|
+| Google Workspace | 知名度高、整合度好 | 價格較高、資料在國外 | 本地化、價格優勢 |
+| Microsoft 365 | 功能完整、企業級 | 複雜度高、價格高 | 簡單易用、中小企業專注 |
+| Zoho Workplace | 價格便宜 | 介面較舊、支援不佳 | 更好的 UX、在地支援 |
+
+### 我們的優勢
+
+1. **價格優勢**: 比 Google/Microsoft 便宜 50%
+2. **在地化**: 資料存放台灣、中文支援
+3. **整合性**: 一站式解決方案
+4. **彈性**: 可客製化配置
+5. **支援**: 在地技術支援
+
+## 商業模式
+
+### 訂閱制 SaaS
+
+**定價策略**:
+
+#### 測試期方案 (前 6 個月)
+
+| 方案 | 人數 | 月費 | 年費 (85折) | 包含服務 |
+|------|------|------|------------|---------|
+| 基礎版 | 10人 | NT$ 500 | NT$ 5,100 | HR + Mail + Drive (5GB/人) |
+| 標準版 | 50人 | NT$ 1,500 | NT$ 15,300 | 基礎 + Calendar + Office |
+| 企業版 | 100人 | NT$ 3,000 | NT$ 30,600 | 標準 + 優先支援 + 10GB/人 |
+
+#### 正式方案 (6 個月後)
+
+| 方案 | 人數 | 月費 | 年費 (85折) |
+|------|------|------|------------|
+| 基礎版 | 10人 | NT$ 800 | NT$ 8,160 |
+| 標準版 | 50人 | NT$ 2,500 | NT$ 25,500 |
+| 企業版 | 100人 | NT$ 5,000 | NT$ 51,000 |
+
+### 額外服務
+
+- **超量使用**: NT$ 50/人/月
+- **額外儲存**: NT$ 100/10GB/月
+- **客製化開發**: 另報價
+- **專業服務**: NT$ 2,000/小時
+
+## 營收預測
+
+### 第一年 (2026)
+
+| 月份 | 付費客戶 | 平均月費 | 月營收 | 累計營收 |
+|------|---------|---------|--------|---------|
+| M1-2 | 0 | - | NT$ 0 | NT$ 0 |
+| M3 | 3 | NT$ 1,000 | NT$ 3,000 | NT$ 3,000 |
+| M4 | 5 | NT$ 1,000 | NT$ 5,000 | NT$ 8,000 |
+| M5 | 8 | NT$ 1,000 | NT$ 8,000 | NT$ 16,000 |
+| M6 | 12 | NT$ 1,200 | NT$ 14,400 | NT$ 30,400 |
+| M7 | 15 | NT$ 1,200 | NT$ 18,000 | NT$ 48,400 |
+| M8 | 20 | NT$ 1,500 | NT$ 30,000 | NT$ 78,400 |
+| M9 | 25 | NT$ 1,500 | NT$ 37,500 | NT$ 115,900 |
+| M10 | 30 | NT$ 1,500 | NT$ 45,000 | NT$ 160,900 |
+| M11 | 35 | NT$ 1,500 | NT$ 52,500 | NT$ 213,400 |
+| M12 | 40 | NT$ 1,500 | NT$ 60,000 | NT$ 273,400 |
+
+**第一年總營收**: NT$ 273,400
+
+### 第二年目標
+
+- 客戶數: 100 家
+- 平均月費: NT$ 2,000
+- 年營收: NT$ 2,400,000
+
+### 第三年目標
+
+- 客戶數: 200 家
+- 平均月費: NT$ 2,500
+- 年營收: NT$ 6,000,000
+
+## 成本結構
+
+### 固定成本 (月)
+
+- 伺服器租用: NT$ 10,000
+- 網路頻寬: NT$ 5,000
+- SSL 憑證: NT$ 1,000
+- 備份儲存: NT$ 2,000
+- **小計**: NT$ 18,000/月
+
+### 變動成本
+
+- 客服人力: NT$ 50/客戶/月
+- 技術支援: NT$ 30/客戶/月
+- 行銷推廣: 營收的 20%
+
+### 損益平衡點
+
+固定成本 / (平均月費 - 變動成本) = 18,000 / (1,500 - 80) ≈ **13 個客戶**
+
+## 行銷策略
+
+### 獲客管道
+
+1. **內容行銷** (免費)
+ - 撰寫技術部落格
+ - SEO 優化
+ - 社群媒體經營
+
+2. **口碑推薦** (低成本)
+ - 推薦獎勵計畫
+ - 客戶見證影片
+ - 案例研究分享
+
+3. **付費廣告** (初期投入)
+ - Google Ads (關鍵字: 虛擬辦公室、企業郵件)
+ - Facebook Ads (目標: 中小企業主)
+ - LinkedIn Ads (B2B 客戶)
+
+4. **合作夥伴**
+ - 會計師事務所
+ - 企業顧問公司
+ - 創業加速器
+
+### 客戶獲取成本 (CAC)
+
+- 目標 CAC: NT$ 3,000/客戶
+- 客戶終身價值 (LTV): NT$ 36,000 (假設留存 2 年)
+- LTV/CAC 比: 12:1 (健康指標)
+
+## 風險評估
+
+### 主要風險
+
+1. **技術風險**
+ - 服務穩定性問題
+ - 資料安全疑慮
+ - **應對**: 完善測試、定期備份、安全稽核
+
+2. **市場風險**
+ - 客戶獲取困難
+ - 競爭加劇
+ - **應對**: 差異化定位、優質服務
+
+3. **財務風險**
+ - 初期現金流不足
+ - 收款困難
+ - **應對**: 預收年費、自動扣款
+
+## 里程碑
+
+### Phase 1: MVP (Week 1-6)
+- [ ] 完成系統開發
+- [ ] 內部測試
+- [ ] 準備行銷素材
+
+### Phase 2: Beta (Week 7-8)
+- [ ] 招募 5 家測試客戶
+- [ ] 收集回饋改進
+- [ ] 優化使用者體驗
+
+### Phase 3: Launch (Week 9-12)
+- [ ] 正式對外發布
+- [ ] 啟動行銷活動
+- [ ] 目標達成 10 個付費客戶
+
+### Phase 4: Growth (Month 4-12)
+- [ ] 持續優化產品
+- [ ] 擴大行銷投入
+- [ ] 目標達成 40 個付費客戶
+
+## 退場策略
+
+1. **被收購**: 目標估值 NT$ 30,000,000 (3 年後)
+2. **持續經營**: 建立穩定現金流
+3. **技術授權**: 授權給大型企業
+
+---
+
+**下一步行動**:
+1. 完成 MVP 開發
+2. 準備行銷素材
+3. 洽談 Beta 測試客戶
diff --git a/docs/開發規範.md b/docs/開發規範.md
new file mode 100644
index 0000000..a013d1a
--- /dev/null
+++ b/docs/開發規範.md
@@ -0,0 +1,373 @@
+# Virtual MIS - 開發規範
+
+**版本**: v1.0
+**日期**: 2026-02-27
+
+---
+
+## 專案結構
+
+```
+virtual-mis/
+├── backend/ # 後端服務
+│ ├── app/
+│ │ ├── api/ # API 路由
+│ │ │ ├── v1/
+│ │ │ │ ├── tenant_onboarding.py
+│ │ │ │ ├── service_integration.py
+│ │ │ │ └── billing.py
+│ │ │ └── router.py
+│ │ ├── core/ # 核心配置
+│ │ │ ├── config.py
+│ │ │ ├── security.py
+│ │ │ └── dependencies.py
+│ │ ├── db/ # 資料庫
+│ │ │ ├── base.py
+│ │ │ └── session.py
+│ │ ├── models/ # ORM 模型
+│ │ ├── schemas/ # Pydantic Schema
+│ │ ├── services/ # 業務邏輯
+│ │ └── utils/ # 工具函數
+│ ├── alembic/ # 資料庫遷移
+│ ├── tests/ # 測試
+│ ├── .env # 環境變數
+│ ├── requirements.txt # 依賴套件
+│ └── main.py # 應用入口
+│
+├── frontend/ # 前端服務
+│ ├── admin-portal/ # 管理後台
+│ │ ├── app/ # Next.js App Router
+│ │ ├── components/ # React 元件
+│ │ ├── lib/ # 工具函數
+│ │ ├── public/ # 靜態資源
+│ │ └── package.json
+│ │
+│ └── landing-page/ # 行銷頁面
+│ ├── app/
+│ ├── components/
+│ └── package.json
+│
+└── docs/ # 文件
+ ├── architecture/ # 架構設計
+ ├── api-specs/ # API 規格
+ ├── business/ # 商業計畫
+ └── deployment/ # 部署文件
+```
+
+## 開發環境
+
+### 後端環境
+
+**Python 版本**: 3.11+
+
+**Port 配置**:
+- 開發環境: `10281`
+- 測試環境: `10282`
+
+**資料庫**:
+- Host: `10.1.0.20`
+- Port: `5433`
+- Database: `virtual_mis`
+- User: `admin`
+
+**啟動方式**:
+```bash
+cd D:\_Develop\porscheworld_develop\virtual-mis\backend
+START_BACKEND.bat
+```
+
+### 前端環境
+
+**Node.js 版本**: 20+
+
+**Port 配置**:
+- Admin Portal: `10280`
+- Landing Page: `10290`
+
+**啟動方式**:
+```bash
+cd D:\_Develop\porscheworld_develop\virtual-mis\frontend\admin-portal
+START_FRONTEND.bat
+```
+
+## 編碼規範
+
+### Python (後端)
+
+1. **命名規範**:
+ - 檔案名稱: `snake_case.py`
+ - 類別名稱: `PascalCase`
+ - 函數名稱: `snake_case`
+ - 常數: `UPPER_CASE`
+
+2. **程式碼風格**:
+ - 使用 Black 格式化
+ - 遵循 PEP 8
+ - 最大行長: 100 字元
+
+3. **型別標註**:
+ ```python
+ def get_tenant(tenant_id: int) -> Optional[Tenant]:
+ """取得租戶資料"""
+ pass
+ ```
+
+4. **文件字串**:
+ ```python
+ def create_tenant(data: TenantCreate) -> Tenant:
+ """
+ 建立新租戶
+
+ Args:
+ data: 租戶建立資料
+
+ Returns:
+ Tenant: 建立的租戶物件
+
+ Raises:
+ ValueError: 租戶代碼已存在
+ """
+ pass
+ ```
+
+### TypeScript (前端)
+
+1. **命名規範**:
+ - 檔案名稱: `kebab-case.tsx`
+ - 元件名稱: `PascalCase`
+ - 函數名稱: `camelCase`
+ - 介面: `PascalCase` (前綴 I 可選)
+
+2. **元件結構**:
+ ```typescript
+ 'use client'
+
+ import { useState } from 'react'
+
+ interface Props {
+ title: string
+ onSubmit: (data: FormData) => void
+ }
+
+ export default function MyComponent({ title, onSubmit }: Props) {
+ const [loading, setLoading] = useState(false)
+
+ return (
+
+
{title}
+
+ )
+ }
+ ```
+
+3. **型別定義**:
+ ```typescript
+ // types/tenant.ts
+ export interface Tenant {
+ id: number
+ code: string
+ name: string
+ status: 'trial' | 'active' | 'suspended'
+ }
+ ```
+
+## API 設計規範
+
+### RESTful 規範
+
+**URL 命名**:
+- 使用名詞複數: `/api/v1/tenants`
+- 使用 kebab-case: `/api/v1/tenant-onboarding`
+- 版本控制: `/api/v1/`, `/api/v2/`
+
+**HTTP 方法**:
+- `GET`: 查詢資料
+- `POST`: 建立資料
+- `PUT`: 完整更新
+- `PATCH`: 部分更新
+- `DELETE`: 刪除資料
+
+**回應格式**:
+```json
+{
+ "success": true,
+ "data": {
+ "id": 1,
+ "name": "測試公司"
+ },
+ "message": "操作成功"
+}
+```
+
+**錯誤回應**:
+```json
+{
+ "success": false,
+ "error": {
+ "code": "TENANT_NOT_FOUND",
+ "message": "找不到指定的租戶",
+ "details": {}
+ }
+}
+```
+
+### Schema 設計
+
+**命名規範**:
+- Base Schema: `TenantBase`
+- Create Schema: `TenantCreate`
+- Update Schema: `TenantUpdate`
+- Response Schema: `TenantResponse`
+
+**範例**:
+```python
+from pydantic import BaseModel, Field
+
+class TenantBase(BaseModel):
+ """租戶基礎 Schema"""
+ code: str = Field(..., max_length=50, description="租戶代碼")
+ name: str = Field(..., max_length=200, description="公司名稱")
+
+class TenantCreate(TenantBase):
+ """建立租戶 Schema"""
+ admin_email: str = Field(..., description="管理員郵箱")
+
+class TenantResponse(TenantBase):
+ """租戶回應 Schema"""
+ id: int
+ status: str
+ created_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+```
+
+## 資料庫規範
+
+### Migration 管理
+
+**命名規則**:
+```
+{timestamp}_{description}.py
+例如: 20260227_create_subscriptions_table.py
+```
+
+**Migration 內容**:
+```python
+def upgrade() -> None:
+ """升級操作"""
+ op.create_table(
+ 'subscriptions',
+ sa.Column('id', sa.Integer(), primary_key=True),
+ sa.Column('tenant_id', sa.Integer(), nullable=False),
+ # ...
+ )
+
+def downgrade() -> None:
+ """降級操作"""
+ op.drop_table('subscriptions')
+```
+
+### 表格命名
+
+- 使用複數形式: `subscriptions`, `invoices`
+- 使用 snake_case
+- 加入前綴表示模組: `billing_invoices`
+
+## Git 工作流程
+
+### 分支策略
+
+- `master`: 生產環境
+- `develop`: 開發環境
+- `feature/*`: 功能開發
+- `hotfix/*`: 緊急修復
+
+### Commit 訊息
+
+**格式**:
+```
+<類型>: <簡短描述>
+
+<詳細說明>
+
+Co-Authored-By: Claude Sonnet 4.5
+```
+
+**類型**:
+- `feat`: 新功能
+- `fix`: 錯誤修復
+- `docs`: 文件更新
+- `refactor`: 重構
+- `test`: 測試
+- `chore`: 雜項
+
+**範例**:
+```
+feat: 新增租戶開通 API
+
+實作租戶自動開通功能,包含:
+- 建立租戶資料
+- 配置網域
+- 建立 Keycloak Realm
+- 初始化服務
+
+Co-Authored-By: Claude Sonnet 4.5
+```
+
+## 測試規範
+
+### 單元測試
+
+**覆蓋率目標**: 80%+
+
+**測試檔案命名**:
+```
+tests/
+├── unit/
+│ ├── test_tenant_service.py
+│ └── test_billing_service.py
+└── integration/
+ ├── test_api_tenants.py
+ └── test_api_billing.py
+```
+
+**測試範例**:
+```python
+import pytest
+from app.services.tenant_service import create_tenant
+
+def test_create_tenant_success():
+ """測試建立租戶成功"""
+ data = TenantCreate(
+ code="testcompany",
+ name="測試公司",
+ admin_email="admin@test.com"
+ )
+
+ tenant = create_tenant(data)
+
+ assert tenant.code == "testcompany"
+ assert tenant.status == "trial"
+```
+
+## 安全規範
+
+1. **環境變數**: 所有敏感資訊放在 `.env`
+2. **密碼處理**: 使用 bcrypt 雜湊
+3. **API 驗證**: 所有 API 需要 JWT token
+4. **輸入驗證**: 使用 Pydantic 驗證所有輸入
+5. **SQL 注入**: 使用 ORM,避免原生 SQL
+
+## 文件規範
+
+1. **README.md**: 每個專案必須包含
+2. **API 文件**: 使用 FastAPI 自動生成 (Swagger)
+3. **程式碼註解**: 複雜邏輯必須註解
+4. **設計文件**: 重要功能需要設計文件
+
+---
+
+**參考資源**:
+- [FastAPI 官方文件](https://fastapi.tiangolo.com/)
+- [Next.js 官方文件](https://nextjs.org/docs)
+- [Python PEP 8](https://peps.python.org/pep-0008/)
diff --git a/frontend/admin-portal/.env.production b/frontend/admin-portal/.env.production
new file mode 100644
index 0000000..bf26e67
--- /dev/null
+++ b/frontend/admin-portal/.env.production
@@ -0,0 +1,8 @@
+# Production Environment Variables
+NEXT_PUBLIC_API_URL=https://vmis.lab.taipei/api
+
+# Keycloak Configuration
+NEXT_PUBLIC_KEYCLOAK_URL=https://auth.lab.taipei
+NEXT_PUBLIC_KEYCLOAK_REALM=vmis-admin
+NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=vmis-admin-portal
+NEXT_PUBLIC_REDIRECT_URI=https://vmis.lab.taipei/callback
diff --git a/frontend/admin-portal/.gitignore b/frontend/admin-portal/.gitignore
new file mode 100644
index 0000000..ed12896
--- /dev/null
+++ b/frontend/admin-portal/.gitignore
@@ -0,0 +1,39 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# env files
+.env*.local
+.env
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/frontend/admin-portal/START_FRONTEND.bat b/frontend/admin-portal/START_FRONTEND.bat
new file mode 100644
index 0000000..5623cbc
--- /dev/null
+++ b/frontend/admin-portal/START_FRONTEND.bat
@@ -0,0 +1,4 @@
+@echo off
+echo Starting VMIS Admin Portal at http://localhost:10280
+echo Press Ctrl+C to stop
+python -m http.server 10280
diff --git a/frontend/admin-portal/accounts.html b/frontend/admin-portal/accounts.html
new file mode 100644
index 0000000..168f4d6
--- /dev/null
+++ b/frontend/admin-portal/accounts.html
@@ -0,0 +1,284 @@
+
+
+
+
+
+ 帳號管理 — VMIS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
帳號管理
+
+
+
+
+
+
+
+
+
+
+
+
+ | 帳號編碼 |
+ 租戶 |
+ SSO 帳號 |
+ 系統郵件 |
+ 法定姓名 |
+ 啟用 |
+ SSO |
+ 郵箱 |
+ Drive |
+ Mail |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/admin-portal/css/style.css b/frontend/admin-portal/css/style.css
new file mode 100644
index 0000000..caed678
--- /dev/null
+++ b/frontend/admin-portal/css/style.css
@@ -0,0 +1,161 @@
+/* VMIS Admin Portal - Custom Styles */
+
+:root {
+ --sidebar-width: 220px;
+ --sidebar-bg: #1a1d23;
+ --sidebar-active: #0d6efd;
+ --header-height: 56px;
+}
+
+body {
+ font-size: 0.875rem;
+ background: #f5f6fa;
+}
+
+/* ── Sidebar ── */
+#sidebar {
+ width: var(--sidebar-width);
+ min-height: 100vh;
+ background: var(--sidebar-bg);
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+}
+
+#sidebar .brand {
+ height: var(--header-height);
+ display: flex;
+ align-items: center;
+ padding: 0 1.2rem;
+ border-bottom: 1px solid rgba(255,255,255,0.08);
+ color: #fff;
+ font-weight: 700;
+ font-size: 1rem;
+ letter-spacing: 0.03em;
+ text-decoration: none;
+}
+
+#sidebar .nav-link {
+ color: rgba(255,255,255,0.65);
+ padding: 0.6rem 1.2rem;
+ border-radius: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 0.85rem;
+ transition: background 0.15s, color 0.15s;
+}
+
+#sidebar .nav-link:hover {
+ color: #fff;
+ background: rgba(255,255,255,0.07);
+}
+
+#sidebar .nav-link.active {
+ color: #fff;
+ background: var(--sidebar-active);
+}
+
+#sidebar .nav-section {
+ color: rgba(255,255,255,0.3);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ padding: 1rem 1.2rem 0.3rem;
+}
+
+/* ── Main Content ── */
+#main {
+ margin-left: var(--sidebar-width);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+#topbar {
+ height: var(--header-height);
+ background: #fff;
+ border-bottom: 1px solid #e9ecef;
+ display: flex;
+ align-items: center;
+ padding: 0 1.5rem;
+ position: sticky;
+ top: 0;
+ z-index: 99;
+}
+
+#content {
+ padding: 1.5rem;
+ flex: 1;
+}
+
+/* ── Status Lights ── */
+.light {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ vertical-align: middle;
+}
+.light-grey { background: #aaaaaa; }
+.light-green { background: #28a745; }
+.light-red { background: #dc3545; }
+
+/* ── DataTable tweaks ── */
+.dataTables_wrapper .dataTables_filter input {
+ border: 1px solid #dee2e6;
+ border-radius: 0.375rem;
+ padding: 0.25rem 0.5rem;
+}
+
+.dataTables_wrapper .dataTables_length select {
+ border: 1px solid #dee2e6;
+ border-radius: 0.375rem;
+ padding: 0.25rem 0.5rem;
+}
+
+table.dataTable thead th {
+ border-bottom: 2px solid #dee2e6;
+ background: #f8f9fa;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+table.dataTable tbody tr:hover {
+ background: #f0f4ff;
+}
+
+/* ── Cards ── */
+.stat-card {
+ border: none;
+ border-radius: 0.75rem;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.08);
+}
+
+/* ── Availability bar ── */
+.avail-bar {
+ height: 6px;
+ border-radius: 3px;
+ background: #e9ecef;
+ overflow: hidden;
+}
+.avail-fill {
+ height: 100%;
+ border-radius: 3px;
+ background: #28a745;
+ transition: width 0.4s;
+}
+.avail-fill.warn { background: #ffc107; }
+.avail-fill.danger { background: #dc3545; }
+
+/* ── System status matrix ── */
+.status-matrix .env-col {
+ min-width: 120px;
+ text-align: center;
+}
+
+/* ── Modal ── */
+.modal-header { background: #f8f9fa; }
diff --git a/frontend/admin-portal/img/logo.png b/frontend/admin-portal/img/logo.png
new file mode 100644
index 0000000..1ff8b7c
Binary files /dev/null and b/frontend/admin-portal/img/logo.png differ
diff --git a/frontend/admin-portal/index.html b/frontend/admin-portal/index.html
new file mode 100644
index 0000000..36ed120
--- /dev/null
+++ b/frontend/admin-portal/index.html
@@ -0,0 +1,198 @@
+
+
+
+
+
+ VMIS Admin Portal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/admin-portal/js/api.js b/frontend/admin-portal/js/api.js
new file mode 100644
index 0000000..939277e
--- /dev/null
+++ b/frontend/admin-portal/js/api.js
@@ -0,0 +1,184 @@
+/* VMIS Admin Portal - API utilities */
+
+const API = 'http://localhost:10281/api/v1';
+
+/* ── Auth state ── */
+let _sysSettings = null;
+let _kcInstance = null;
+let _accessToken = null;
+
+/* ── Keycloak SSO guard ── */
+async function _loadKcScript(src) {
+ return new Promise((resolve) => {
+ const s = document.createElement('script');
+ s.src = src;
+ s.onload = () => resolve(true);
+ s.onerror = () => resolve(false);
+ document.head.appendChild(s);
+ });
+}
+
+async function _initKcAuth(settings) {
+ // 嘗試路徑順序:新版(Quarkus) → 舊版(WildFly /auth) → CDN
+ const candidates = [
+ `${settings.keycloak_url}/js/keycloak.js`,
+ `${settings.keycloak_url}/auth/js/keycloak.js`,
+ 'https://cdn.jsdelivr.net/npm/keycloak-js/dist/keycloak.min.js',
+ ];
+ let loaded = false;
+ for (const src of candidates) {
+ loaded = await _loadKcScript(src);
+ if (loaded) { console.info('KC JS 載入成功:', src); break; }
+ }
+ if (!loaded) { console.warn('KC JS 全部來源載入失敗,跳過 SSO 守衛'); return; }
+
+ try {
+ const kc = new Keycloak({
+ url: settings.keycloak_url,
+ realm: settings.keycloak_realm,
+ clientId: settings.keycloak_client || 'vmis-portal',
+ });
+ const authenticated = await kc.init({
+ onLoad: 'login-required',
+ pkceMethod: 'S256',
+ checkLoginIframe: false,
+ });
+ if (authenticated) {
+ _kcInstance = kc;
+ _accessToken = kc.token;
+ setInterval(() => {
+ kc.updateToken(60)
+ .then(refreshed => { if (refreshed) _accessToken = kc.token; })
+ .catch(() => kc.login());
+ }, 30000);
+ // 在 topbar 插入使用者資訊與登出按鈕
+ const topbar = document.getElementById('topbar');
+ if (topbar) {
+ const username = kc.tokenParsed?.preferred_username || kc.tokenParsed?.name || '';
+ const btn = document.createElement('div');
+ btn.className = 'ms-3 d-flex align-items-center gap-2';
+ btn.innerHTML = `
+ ${username}
+ `;
+ topbar.appendChild(btn);
+ }
+ }
+ } catch (e) {
+ console.error('KC 初始化失敗:', e);
+ }
+}
+
+/* ── Global settings (loaded on every page) ── */
+async function loadSysSettings() {
+ try {
+ _sysSettings = await apiFetch('/settings');
+ // Apply site title: prepend page name if title contains " — "
+ if (_sysSettings.site_title) {
+ const cur = document.title;
+ const sep = cur.indexOf(' — ');
+ const pageName = sep >= 0 ? cur.substring(0, sep) : cur;
+ document.title = `${pageName} — ${_sysSettings.site_title}`;
+ }
+ // Inject version badge into topbar if element exists
+ const topbar = document.getElementById('topbar');
+ if (topbar && _sysSettings.version) {
+ const badge = document.createElement('span');
+ badge.className = 'badge bg-secondary-subtle text-secondary ms-2';
+ badge.textContent = `v${_sysSettings.version}`;
+ topbar.querySelector('.fw-semibold')?.after(badge);
+ }
+ // SSO guard:若已啟用,觸發 Keycloak 認證
+ if (_sysSettings.sso_enabled && _sysSettings.keycloak_url && _sysSettings.keycloak_realm) {
+ await _initKcAuth(_sysSettings);
+ }
+ } catch (e) {
+ // Silently ignore if settings not yet available
+ }
+}
+
+// Auto-load on every page
+document.addEventListener('DOMContentLoaded', loadSysSettings);
+
+async function apiFetch(path, options = {}) {
+ const headers = { 'Content-Type': 'application/json', ...options.headers };
+ if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;
+ const resp = await fetch(API + path, { headers, ...options });
+ if (!resp.ok) {
+ // Token 過期 → 重新登入
+ if (resp.status === 401 && _kcInstance) { _kcInstance.login(); return; }
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
+ throw Object.assign(new Error(err.detail || resp.statusText), { status: resp.status });
+ }
+ if (resp.status === 204) return null;
+ return resp.json();
+}
+
+/* ── light helper ── */
+function lightHtml(val) {
+ if (val === null || val === undefined) return '';
+ return val
+ ? ''
+ : '';
+}
+
+/* ── availability color ── */
+function availClass(pct) {
+ if (pct === null || pct === undefined) return '';
+ if (pct >= 99) return '';
+ if (pct >= 95) return 'warn';
+ return 'danger';
+}
+
+function availBar(pct) {
+ if (pct === null || pct === undefined) return '—';
+ const cls = availClass(pct);
+ return `
+ `;
+}
+
+/* ── toast ── */
+function toast(msg, type = 'success') {
+ const container = document.getElementById('toast-container');
+ if (!container) return;
+ const id = 'toast-' + Date.now();
+ const icon = type === 'success' ? '✓' : '✗';
+ const bg = type === 'success' ? 'text-bg-success' : 'text-bg-danger';
+ container.insertAdjacentHTML('beforeend', `
+ `);
+ setTimeout(() => document.getElementById(id)?.remove(), 3500);
+}
+
+/* ── confirm modal ── */
+function confirm(msg) {
+ return new Promise(resolve => {
+ document.getElementById('confirm-body').textContent = msg;
+ const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('confirmModal'));
+ document.getElementById('confirm-ok').onclick = () => { modal.hide(); resolve(true); };
+ document.getElementById('confirm-cancel').onclick = () => { modal.hide(); resolve(false); };
+ modal.show();
+ });
+}
+
+/* ── format datetime ── */
+// 後端已依設定時區儲存,直接顯示資料庫原始時間,不做轉換
+function fmtDt(iso) {
+ if (!iso) return '—';
+ return iso.replace('T', ' ').substring(0, 19);
+}
+
+function fmtDate(iso) {
+ if (!iso) return '—';
+ return iso.substring(0, 10);
+}
diff --git a/frontend/admin-portal/schedule-logs.html b/frontend/admin-portal/schedule-logs.html
new file mode 100644
index 0000000..3c30787
--- /dev/null
+++ b/frontend/admin-portal/schedule-logs.html
@@ -0,0 +1,251 @@
+
+
+
+
+
+ 排程執行紀錄 — VMIS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
排程執行紀錄
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 排程名稱 |
+ 開始時間 |
+ 結束時間 |
+ 耗時 |
+ 結果 |
+ 詳細 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/admin-portal/schedules.html b/frontend/admin-portal/schedules.html
new file mode 100644
index 0000000..2c5cf27
--- /dev/null
+++ b/frontend/admin-portal/schedules.html
@@ -0,0 +1,177 @@
+
+
+
+
+
+ 排程管理 — VMIS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
範例:0 */3 * * * * = 每3分鐘,0 0 8 * * * = 每日08:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/admin-portal/servers.html b/frontend/admin-portal/servers.html
new file mode 100644
index 0000000..3a25d59
--- /dev/null
+++ b/frontend/admin-portal/servers.html
@@ -0,0 +1,253 @@
+
+
+
+
+
+ 伺服器狀態 — VMIS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
伺服器狀態
+
+
+
+
+
+
+
+
+
+
+
+ | 排序 |
+ 主機名稱 |
+ IP 位址 |
+ 說明 |
+ 狀態 |
+ 回應時間 |
+ 可用率 30天 |
+ 可用率 90天 |
+ 可用率 365天 |
+ 啟用 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/admin-portal/settings.html b/frontend/admin-portal/settings.html
new file mode 100644
index 0000000..1895d0c
--- /dev/null
+++ b/frontend/admin-portal/settings.html
@@ -0,0 +1,324 @@
+
+
+
+
+
+ 系統設定 — VMIS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 系統初始化提示:
+ 請依序完成以下步驟以啟動 SSO 認證:
+ 1設定 Keycloak 連線資訊並測試連線
+ 2初始化 SSO Realm
+ 3建立管理租戶 (is_manager=true) 及帳號
+ 4啟用 SSO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
瀏覽器 tab 顯示的標題文字
+
+
+
+
+
顯示於頁面底部的系統版本號
+
+
+
+
+
資料庫紀錄儲存使用的時區,變更後需重啟後端才完全生效
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 啟用後,進入系統必須通過 Keycloak 登入驗證。
+ 前提:需有管理租戶 (is_manager=true) 及其帳號。
+
+
+
+
+
+
Master Realm 管理帳號(後端操作租戶 realm 用)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Admin Portal 前端登入設定
+
+
+
+
前端登入用的 Keycloak Public Client ID。SSO Realm 由
管理租戶的 Keycloak Realm 決定,請先至
租戶管理設定 is_manager=true 的租戶。
+
+
+
+
+
+
+
+
+ 在管理租戶的 Realm 中建立前端 Client ID(依序:儲存設定 → 測試連線 → 建立管理租戶 → 再執行)
+
+
+
+
+
+
+
+
+
+
+
+ 系統名稱
+ —
+
+
+ 版本
+ —
+
+
+ 時區
+ —
+
+
+ SSO
+ —
+
+
+ Keycloak Realm
+ —
+
+
+ 管理租戶
+ —
+
+
+ 最後更新
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/admin-portal/system-status.html b/frontend/admin-portal/system-status.html
new file mode 100644
index 0000000..d843cd1
--- /dev/null
+++ b/frontend/admin-portal/system-status.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+ 系統狀態 — VMIS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
尚無資料,請等待系統狀態排程執行(每日 08:00)
+
+
+
+
+
+
+ 正常
+ 異常
+ 無資料
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/admin-portal/tenants.html b/frontend/admin-portal/tenants.html
new file mode 100644
index 0000000..b81b02a
--- /dev/null
+++ b/frontend/admin-portal/tenants.html
@@ -0,0 +1,321 @@
+
+
+
+
+
+ 租戶管理 — VMIS Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 租戶代碼 |
+ 中文名稱 |
+ 網域 |
+ 狀態 |
+ 啟用 |
+ SSO |
+ 郵箱 |
+ Drive |
+ Office |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+