feat(vmis): 租戶自動開通完整流程 + Admin Portal SSO + NC 行事曆訂閱

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 <noreply@anthropic.com>
This commit is contained in:
VMIS Developer
2026-03-15 15:31:37 +08:00
parent 42d1420f9c
commit 62baadb06f
53 changed files with 5638 additions and 195 deletions

View File

@@ -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 的 mailboxdocker-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 不需要顯式建立 domainmailbox 新增時自動處理)。
新增一個 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