From 62baadb06f8910e692b0e9a5821235b75b793412 Mon Sep 17 00:00:00 2001 From: VMIS Developer Date: Sun, 15 Mar 2026 15:31:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(vmis):=20=E7=A7=9F=E6=88=B6=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E9=96=8B=E9=80=9A=E5=AE=8C=E6=95=B4=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=20+=20Admin=20Portal=20SSO=20+=20NC=20=E8=A1=8C=E4=BA=8B?= =?UTF-8?q?=E6=9B=86=E8=A8=82=E9=96=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - schedule_tenant: NC 新容器自動 pgsql 安裝 (_nc_db_check 全新容器處理) - schedule_tenant: NC 初始化加入 Redis + APCu memcache 設定 (修正 OIDC invalid_state) - schedule_tenant: 新租戶 KC realm 自動設定 accessCodeLifespan=600s (修正 authentication_expired) - schedule_account: NC Mail 帳號自動設定 (nc_mail_result/nc_mail_done_at) - schedule_account: NC 台灣國定假日行事曆自動訂閱 (CalDAV MKCALENDAR) - nextcloud_client: 新增 subscribe_calendar() CalDAV 訂閱方法 - settings: 新增系統設定 API (site_title/version/timezone/SSO/Keycloak) - models/result: 新增 nc_mail_result, nc_mail_done_at 欄位 - alembic: 遷移 002(system_settings) 003(keycloak_admin) 004(nc_mail_result) Frontend (Admin Portal): - 新增完整管理後台 (index/tenants/accounts/servers/schedules/logs/settings/system-status) - api.js: Keycloak JS Adapter SSO 整合 (PKCE/S256, fallback KC JS 來源, 自動 token 更新) - index.html: Promise.allSettled 取代 Promise.all,防止單一 API 失敗影響整頁 - 所有頁面加入 try/catch + toast 錯誤處理 - 新增品牌 LOGO 與 favicon Co-Authored-By: Claude Sonnet 4.6 --- backend/START_BACKEND.bat | 6 +- .../versions/002_add_system_settings.py | 33 + .../003_add_keycloak_admin_credentials.py | 25 + .../versions/004_add_nc_mail_result.py | 23 + backend/app/api/v1/accounts.py | 8 +- backend/app/api/v1/router.py | 3 +- backend/app/api/v1/schedules.py | 66 +- backend/app/api/v1/servers.py | 3 +- backend/app/api/v1/settings.py | 135 +++ backend/app/core/config.py | 5 + backend/app/core/database.py | 1 + backend/app/core/utils.py | 18 + backend/app/models/__init__.py | 2 + backend/app/models/account.py | 5 +- backend/app/models/result.py | 8 +- backend/app/models/schedule.py | 3 +- backend/app/models/server.py | 7 +- backend/app/models/settings.py | 22 + backend/app/models/tenant.py | 5 +- backend/app/schemas/account.py | 1 + backend/app/schemas/schedule.py | 38 +- backend/app/schemas/settings.py | 31 + backend/app/services/docker_client.py | 123 ++- backend/app/services/keycloak_client.py | 177 +++- backend/app/services/mail_client.py | 96 +- backend/app/services/nextcloud_client.py | 62 +- backend/app/services/scheduler/runner.py | 24 +- .../services/scheduler/schedule_account.py | 115 +- .../app/services/scheduler/schedule_system.py | 5 +- .../app/services/scheduler/schedule_tenant.py | 996 +++++++++++++++++- backend/app/services/scheduler/watchdog.py | 13 +- backend/app/services/seed.py | 15 +- docker/radicale/README.md | 249 +++++ docker/radicale/config/config | 34 + docker/radicale/docker-compose.yml | 44 + docs/WebMail_SSO_Integration_Guide.md | 254 +++++ docs/architecture/01-系統架構設計.md | 225 ++++ docs/business/商業計畫.md | 213 ++++ docs/開發規範.md | 373 +++++++ frontend/admin-portal/.env.production | 8 + frontend/admin-portal/.gitignore | 39 + frontend/admin-portal/START_FRONTEND.bat | 4 + frontend/admin-portal/accounts.html | 284 +++++ frontend/admin-portal/css/style.css | 161 +++ frontend/admin-portal/img/logo.png | Bin 0 -> 18001 bytes frontend/admin-portal/index.html | 198 ++++ frontend/admin-portal/js/api.js | 184 ++++ frontend/admin-portal/schedule-logs.html | 251 +++++ frontend/admin-portal/schedules.html | 177 ++++ frontend/admin-portal/servers.html | 253 +++++ frontend/admin-portal/settings.html | 324 ++++++ frontend/admin-portal/system-status.html | 163 +++ frontend/admin-portal/tenants.html | 321 ++++++ 53 files changed, 5638 insertions(+), 195 deletions(-) create mode 100644 backend/alembic/versions/002_add_system_settings.py create mode 100644 backend/alembic/versions/003_add_keycloak_admin_credentials.py create mode 100644 backend/alembic/versions/004_add_nc_mail_result.py create mode 100644 backend/app/api/v1/settings.py create mode 100644 backend/app/core/utils.py create mode 100644 backend/app/models/settings.py create mode 100644 backend/app/schemas/settings.py create mode 100644 docker/radicale/README.md create mode 100644 docker/radicale/config/config create mode 100644 docker/radicale/docker-compose.yml create mode 100644 docs/WebMail_SSO_Integration_Guide.md create mode 100644 docs/architecture/01-系統架構設計.md create mode 100644 docs/business/商業計畫.md create mode 100644 docs/開發規範.md create mode 100644 frontend/admin-portal/.env.production create mode 100644 frontend/admin-portal/.gitignore create mode 100644 frontend/admin-portal/START_FRONTEND.bat create mode 100644 frontend/admin-portal/accounts.html create mode 100644 frontend/admin-portal/css/style.css create mode 100644 frontend/admin-portal/img/logo.png create mode 100644 frontend/admin-portal/index.html create mode 100644 frontend/admin-portal/js/api.js create mode 100644 frontend/admin-portal/schedule-logs.html create mode 100644 frontend/admin-portal/schedules.html create mode 100644 frontend/admin-portal/servers.html create mode 100644 frontend/admin-portal/settings.html create mode 100644 frontend/admin-portal/system-status.html create mode 100644 frontend/admin-portal/tenants.html 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郵箱DriveMail操作
+
+
+
+
+ + + + + + + +
+ + + + + + + + + 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 0000000000000000000000000000000000000000..1ff8b7c0e1e5eb1278959519529169314e3a214a GIT binary patch literal 18001 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 Y2i8}7%A)X0CjbBd07*qoM6N<$f=rA-EC2ui literal 0 HcmV?d00001 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 ` +
+
+
+
+ ${pct}% +
`; +} + +/* ── 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 + + + + + + + + + + + +
+
+ 排程管理 +
+ +
+
+
+
+ + + + +
+ + + + + + + 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 顯示的標題文字
+
+
+ + +
顯示於頁面底部的系統版本號
+
+
+ + +
資料庫紀錄儲存使用的時區,變更後需重啟後端才完全生效
+
+
+
+
+ + +
+
+
+ SSO 驗證設定 +
+
+ + +
+
+ + +
+
+ 啟用後,進入系統必須通過 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郵箱DriveOffice操作
+
+
+
+
+ + + + + + + + +
+ + + + + + + + +