feat(backend): Phase 1-4 全新開發完成,37/37 TDD 通過
[Phase 0 Reset]
- 清除舊版 app/、alembic/versions/、雜亂測試腳本
- 新 requirements.txt (移除 caldav/redis/keycloak-lib,加入 apscheduler/croniter/docker/paramiko/ping3/dnspython)
[Phase 1 資料庫]
- 9 張資料表 SQLAlchemy Models:tenants / accounts / schedules / schedule_logs /
tenant_schedule_results / account_schedule_results / servers / server_status_logs / system_status_logs
- Alembic migration 001_create_all_tables (已套用到 10.1.0.20:5433/virtual_mis)
- seed.py:schedules 初始 3 筆 / servers 初始 4 筆
[Phase 2 CRUD API]
- GET/POST/PUT/DELETE: /api/v1/tenants / accounts / servers / schedules
- /api/v1/system-status
- 帳號編碼自動產生 (prefix + seq_no 4碼左補0)
- 燈號 (lights) 從最新排程結果取得
[Phase 3 Watchdog]
- APScheduler interval 3分鐘,原子 UPDATE status=Going 防重複執行
- 手動觸發 API: POST /api/v1/schedules/{id}/run
[Phase 4 Service Clients]
- KeycloakClient:vmis-admin realm,REST API (不用 python-keycloak)
- MailClient:Docker Mailserver @ 10.1.0.254:8080,含 MX DNS 驗證
- DockerClient:docker-py 本機 + paramiko SSH 遠端 compose
- NextcloudClient:OCS API user/quota
- SystemChecker:功能驗證 (traefik routers>0 / keycloak token / SMTP EHLO / DB SELECT 1 / ping)
[TDD]
- 37 tests / 37 passed (2.11s)
- SQLite in-memory + StaticPool,無需外部 DB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
52
backend/alembic/env.py
Normal file
52
backend/alembic/env.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.core.database import Base
|
||||
import app.models # noqa - registers all models
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
206
backend/alembic/versions/001_create_all_tables.py
Normal file
206
backend/alembic/versions/001_create_all_tables.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""create all tables
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-03-14
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── tenants ──────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'tenants',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('code', sa.String(50), nullable=False),
|
||||
sa.Column('prefix', sa.String(20), nullable=False, server_default=''),
|
||||
sa.Column('name', sa.String(200), nullable=False),
|
||||
sa.Column('name_eng', sa.String(200)),
|
||||
sa.Column('tax_id', sa.String(20)),
|
||||
sa.Column('domain', sa.String(200), nullable=False),
|
||||
sa.Column('address', sa.String(500)),
|
||||
sa.Column('tel', sa.String(50)),
|
||||
sa.Column('contact', sa.String(100)),
|
||||
sa.Column('contact_mobile', sa.String(50)),
|
||||
sa.Column('contact_email', sa.String(200)),
|
||||
sa.Column('keycloak_realm', sa.String(100)),
|
||||
sa.Column('plan_code', sa.String(50)),
|
||||
sa.Column('employee_limit', sa.Integer()),
|
||||
sa.Column('trial_start_date', sa.Date()),
|
||||
sa.Column('trial_end_date', sa.Date()),
|
||||
sa.Column('quota_per_user', sa.Integer(), nullable=False, server_default='20'),
|
||||
sa.Column('total_quota', sa.Integer(), nullable=False, server_default='200'),
|
||||
sa.Column('is_manager', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='trial'),
|
||||
sa.Column('note', sa.Text()),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('code', name='uq_tenants_code'),
|
||||
sa.UniqueConstraint('domain', name='uq_tenants_domain'),
|
||||
)
|
||||
op.create_index('ix_tenants_id', 'tenants', ['id'])
|
||||
op.create_index('ix_tenants_code', 'tenants', ['code'])
|
||||
op.create_index('ix_tenants_domain', 'tenants', ['domain'])
|
||||
|
||||
# ── accounts ─────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'accounts',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('account_code', sa.String(20), nullable=False),
|
||||
sa.Column('sso_account', sa.String(100), nullable=False),
|
||||
sa.Column('sso_uuid', sa.String(100)),
|
||||
sa.Column('notification_email', sa.String(200), nullable=False),
|
||||
sa.Column('email', sa.String(200)),
|
||||
sa.Column('legal_name', sa.String(200)),
|
||||
sa.Column('english_name', sa.String(200)),
|
||||
sa.Column('quota_limit', sa.Integer(), nullable=False, server_default='20'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('default_password', sa.String(200)),
|
||||
sa.Column('seq_no', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('account_code', name='uq_accounts_code'),
|
||||
sa.UniqueConstraint('sso_uuid', name='uq_accounts_sso_uuid'),
|
||||
)
|
||||
op.create_index('ix_accounts_id', 'accounts', ['id'])
|
||||
op.create_index('ix_accounts_tenant_id', 'accounts', ['tenant_id'])
|
||||
op.create_index('ix_accounts_account_code', 'accounts', ['account_code'])
|
||||
|
||||
# ── schedules ────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'schedules',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('cron_timer', sa.String(50), nullable=False),
|
||||
sa.Column('status', sa.String(10), nullable=False, server_default='Waiting'),
|
||||
sa.Column('last_run_at', sa.DateTime()),
|
||||
sa.Column('next_run_at', sa.DateTime()),
|
||||
sa.Column('last_status', sa.String(10)),
|
||||
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('name', name='uq_schedules_name'),
|
||||
)
|
||||
op.create_index('ix_schedules_id', 'schedules', ['id'])
|
||||
|
||||
# ── schedule_logs ────────────────────────────────────────
|
||||
op.create_table(
|
||||
'schedule_logs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('schedule_id', sa.Integer(), sa.ForeignKey('schedules.id'), nullable=False),
|
||||
sa.Column('schedule_name', sa.String(100), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('ended_at', sa.DateTime()),
|
||||
sa.Column('status', sa.String(10), nullable=False, server_default='running'),
|
||||
)
|
||||
op.create_index('ix_schedule_logs_id', 'schedule_logs', ['id'])
|
||||
op.create_index('ix_schedule_logs_schedule_id', 'schedule_logs', ['schedule_id'])
|
||||
|
||||
# ── tenant_schedule_results ──────────────────────────────
|
||||
op.create_table(
|
||||
'tenant_schedule_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
||||
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id'), nullable=False),
|
||||
sa.Column('traefik_status', sa.Boolean()),
|
||||
sa.Column('traefik_done_at', sa.DateTime()),
|
||||
sa.Column('sso_result', sa.Boolean()),
|
||||
sa.Column('sso_done_at', sa.DateTime()),
|
||||
sa.Column('mailbox_result', sa.Boolean()),
|
||||
sa.Column('mailbox_done_at', sa.DateTime()),
|
||||
sa.Column('nc_result', sa.Boolean()),
|
||||
sa.Column('nc_done_at', sa.DateTime()),
|
||||
sa.Column('office_result', sa.Boolean()),
|
||||
sa.Column('office_done_at', sa.DateTime()),
|
||||
sa.Column('fail_reason', sa.Text()),
|
||||
sa.Column('quota_usage', sa.Float()),
|
||||
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('ix_tenant_schedule_results_id', 'tenant_schedule_results', ['id'])
|
||||
op.create_index('ix_tenant_schedule_results_log_id', 'tenant_schedule_results', ['schedule_log_id'])
|
||||
op.create_index('ix_tenant_schedule_results_tenant_id', 'tenant_schedule_results', ['tenant_id'])
|
||||
|
||||
# ── account_schedule_results ─────────────────────────────
|
||||
op.create_table(
|
||||
'account_schedule_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
||||
sa.Column('account_id', sa.Integer(), sa.ForeignKey('accounts.id'), nullable=False),
|
||||
sa.Column('sso_uuid', sa.String(100)),
|
||||
sa.Column('sso_account', sa.String(100)),
|
||||
sa.Column('sso_result', sa.Boolean()),
|
||||
sa.Column('sso_done_at', sa.DateTime()),
|
||||
sa.Column('mailbox_result', sa.Boolean()),
|
||||
sa.Column('mailbox_done_at', sa.DateTime()),
|
||||
sa.Column('nc_result', sa.Boolean()),
|
||||
sa.Column('nc_done_at', sa.DateTime()),
|
||||
sa.Column('fail_reason', sa.Text()),
|
||||
sa.Column('quota_usage', sa.Float()),
|
||||
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('ix_account_schedule_results_id', 'account_schedule_results', ['id'])
|
||||
op.create_index('ix_account_schedule_results_log_id', 'account_schedule_results', ['schedule_log_id'])
|
||||
op.create_index('ix_account_schedule_results_account_id', 'account_schedule_results', ['account_id'])
|
||||
|
||||
# ── servers ──────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'servers',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('ip_address', sa.String(50), nullable=False),
|
||||
sa.Column('description', sa.String(200)),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('ip_address', name='uq_servers_ip'),
|
||||
)
|
||||
op.create_index('ix_servers_id', 'servers', ['id'])
|
||||
|
||||
# ── server_status_logs ───────────────────────────────────
|
||||
op.create_table(
|
||||
'server_status_logs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
||||
sa.Column('server_id', sa.Integer(), sa.ForeignKey('servers.id'), nullable=False),
|
||||
sa.Column('result', sa.Boolean(), nullable=False),
|
||||
sa.Column('response_time', sa.Float()),
|
||||
sa.Column('fail_reason', sa.Text()),
|
||||
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('ix_server_status_logs_id', 'server_status_logs', ['id'])
|
||||
op.create_index('ix_server_status_logs_schedule_log_id', 'server_status_logs', ['schedule_log_id'])
|
||||
op.create_index('ix_server_status_logs_server_id', 'server_status_logs', ['server_id'])
|
||||
|
||||
# ── system_status_logs ───────────────────────────────────
|
||||
op.create_table(
|
||||
'system_status_logs',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('schedule_log_id', sa.Integer(), sa.ForeignKey('schedule_logs.id'), nullable=False),
|
||||
sa.Column('environment', sa.String(10), nullable=False),
|
||||
sa.Column('service_name', sa.String(50), nullable=False),
|
||||
sa.Column('service_desc', sa.String(100)),
|
||||
sa.Column('result', sa.Boolean(), nullable=False),
|
||||
sa.Column('fail_reason', sa.Text()),
|
||||
sa.Column('recorded_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
)
|
||||
op.create_index('ix_system_status_logs_id', 'system_status_logs', ['id'])
|
||||
op.create_index('ix_system_status_logs_schedule_log_id', 'system_status_logs', ['schedule_log_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('system_status_logs')
|
||||
op.drop_table('server_status_logs')
|
||||
op.drop_table('servers')
|
||||
op.drop_table('account_schedule_results')
|
||||
op.drop_table('tenant_schedule_results')
|
||||
op.drop_table('schedule_logs')
|
||||
op.drop_table('schedules')
|
||||
op.drop_table('accounts')
|
||||
op.drop_table('tenants')
|
||||
Reference in New Issue
Block a user