feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

66
backend/.env.example Normal file
View File

@@ -0,0 +1,66 @@
# ============================================================================
# HR Portal Backend 環境變數配置
# 複製此文件為 .env 並填入實際值
# ============================================================================
# 基本資訊
PROJECT_NAME="HR Portal API"
VERSION="2.0.0"
ENVIRONMENT="development" # development, staging, production
HOST="0.0.0.0"
PORT=8000
# 資料庫配置
DATABASE_URL="postgresql://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
DATABASE_ECHO=False
# CORS 配置 (多個來源用逗號分隔)
ALLOWED_ORIGINS="http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
# Keycloak 配置
KEYCLOAK_URL="https://auth.ease.taipei"
KEYCLOAK_REALM="porscheworld"
KEYCLOAK_CLIENT_ID="hr-backend"
KEYCLOAK_CLIENT_SECRET="your-client-secret-here"
KEYCLOAK_ADMIN_USERNAME="admin"
KEYCLOAK_ADMIN_PASSWORD="your-admin-password"
# JWT 配置
JWT_SECRET_KEY="your-secret-key-change-in-production"
JWT_ALGORITHM="HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
# 郵件配置 (Docker Mailserver)
MAIL_SERVER="10.1.0.30"
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_ADMIN_USER="admin@porscheworld.tw"
MAIL_ADMIN_PASSWORD="your-mail-admin-password"
# NAS 配置 (Synology DS920+)
NAS_HOST="10.1.0.30"
NAS_PORT=5000
NAS_USERNAME="your-nas-username"
NAS_PASSWORD="your-nas-password"
NAS_WEBDAV_URL="https://nas.lab.taipei/webdav"
NAS_SMB_SHARE="Working"
# 日誌配置
LOG_LEVEL="INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_FILE="logs/hr_portal.log"
# 分頁配置
DEFAULT_PAGE_SIZE=20
MAX_PAGE_SIZE=100
# 郵件配額 (MB)
EMAIL_QUOTA_JUNIOR=1000
EMAIL_QUOTA_MID=2000
EMAIL_QUOTA_SENIOR=5000
EMAIL_QUOTA_MANAGER=10000
# NAS 配額 (GB)
NAS_QUOTA_JUNIOR=50
NAS_QUOTA_MID=100
NAS_QUOTA_SENIOR=200
NAS_QUOTA_MANAGER=500

59
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,59 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
ENV/
env/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment Variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
# Database
*.db
*.sqlite3
# Testing
.coverage
.pytest_cache/
htmlcov/
# OS
.DS_Store
Thumbs.db
# Alembic
alembic/versions/*.pyc

59
backend/Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
# ============================================================================
# HR Portal Backend Dockerfile
# FastAPI + Python 3.11 + PostgreSQL
# ============================================================================
FROM python:3.11-slim as base
# 設定環境變數
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 設定工作目錄
WORKDIR /app
# 安裝系統依賴
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# ============================================================================
# Builder Stage - 安裝 Python 依賴
# ============================================================================
FROM base as builder
# 複製需求檔案
COPY requirements.txt .
# 安裝 Python 依賴
RUN pip install --user --no-warn-script-location -r requirements.txt
# ============================================================================
# Runtime Stage - 最終映像
# ============================================================================
FROM base
# 從 builder 複製已安裝的套件
COPY --from=builder /root/.local /root/.local
# 確保 scripts 在 PATH 中
ENV PATH=/root/.local/bin:$PATH
# 複製應用程式碼
COPY . /app
# 創建日誌目錄
RUN mkdir -p /app/logs
# 暴露端口
EXPOSE 8000
# 健康檢查
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5.0)" || exit 1
# 啟動命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

27
backend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# 安裝系統依賴
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# 複製需求檔案
COPY requirements.txt .
# 安裝 Python 依賴
RUN pip install --no-cache-dir -r requirements.txt
# 複製應用代碼
COPY . .
# 暴露端口
EXPOSE 10181
# 啟動命令 (開發模式)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "10181"]

117
backend/alembic.ini Normal file
View File

@@ -0,0 +1,117 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# sqlalchemy.url will be set from app config in env.py
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

92
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,92 @@
from logging.config import fileConfig
import sys
from pathlib import Path
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add the parent directory to sys.path to import app modules
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Import settings and Base
from app.core.config import settings
from app.db.base import Base
# Import all models for Alembic to detect
# 使用統一的 models import自動包含所有 Models
from app.models import * # noqa: F403, F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Set the SQLAlchemy URL from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
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:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
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()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,114 @@
"""add tenants table for multi-tenant support
Revision ID: 0001_5
Revises: fba4e3f40f05
Create Date: 2026-02-15 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0001_5'
down_revision: Union[str, None] = 'fba4e3f40f05'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 創建 tenants 表
op.create_table(
'tenants',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False, comment='租戶代碼 (唯一)'),
sa.Column('name', sa.String(length=200), nullable=False, comment='租戶名稱'),
sa.Column('status', sa.String(length=20), nullable=False, server_default='active', comment='狀態'),
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.PrimaryKeyConstraint('id')
)
# 創建索引
op.create_index('idx_tenants_code', 'tenants', ['code'], unique=True)
op.create_index('idx_tenants_status', 'tenants', ['status'])
# 添加預設租戶 (Porsche World)
# 注意: keycloak_realm 欄位在 0005 migration 才加入,這裡先不設定
op.execute("""
INSERT INTO tenants (code, name, status)
VALUES ('porscheworld', 'Porsche World', 'active')
""")
# 為現有表添加 tenant_id 欄位
op.add_column('employees', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('business_units', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('departments', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('employee_identities', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('network_drives', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('audit_logs', sa.Column('tenant_id', sa.Integer(), nullable=True))
# 將所有現有記錄設定為預設租戶
op.execute("UPDATE employees SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE business_units SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE departments SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE employee_identities SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE network_drives SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE audit_logs SET tenant_id = 1 WHERE tenant_id IS NULL")
# 將 tenant_id 設為 NOT NULL
op.alter_column('employees', 'tenant_id', nullable=False)
op.alter_column('business_units', 'tenant_id', nullable=False)
op.alter_column('departments', 'tenant_id', nullable=False)
op.alter_column('employee_identities', 'tenant_id', nullable=False)
op.alter_column('network_drives', 'tenant_id', nullable=False)
op.alter_column('audit_logs', 'tenant_id', nullable=False)
# 添加外鍵約束
op.create_foreign_key('fk_employees_tenant', 'employees', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_business_units_tenant', 'business_units', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_departments_tenant', 'departments', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_employee_identities_tenant', 'employee_identities', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_network_drives_tenant', 'network_drives', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_audit_logs_tenant', 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
# 添加索引
op.create_index('idx_employees_tenant', 'employees', ['tenant_id'])
op.create_index('idx_business_units_tenant', 'business_units', ['tenant_id'])
op.create_index('idx_departments_tenant', 'departments', ['tenant_id'])
op.create_index('idx_employee_identities_tenant', 'employee_identities', ['tenant_id'])
op.create_index('idx_network_drives_tenant', 'network_drives', ['tenant_id'])
op.create_index('idx_audit_logs_tenant', 'audit_logs', ['tenant_id'])
def downgrade() -> None:
# 移除索引
op.drop_index('idx_audit_logs_tenant', table_name='audit_logs')
op.drop_index('idx_network_drives_tenant', table_name='network_drives')
op.drop_index('idx_employee_identities_tenant', table_name='employee_identities')
op.drop_index('idx_departments_tenant', table_name='departments')
op.drop_index('idx_business_units_tenant', table_name='business_units')
op.drop_index('idx_employees_tenant', table_name='employees')
# 移除外鍵
op.drop_constraint('fk_audit_logs_tenant', 'audit_logs', type_='foreignkey')
op.drop_constraint('fk_network_drives_tenant', 'network_drives', type_='foreignkey')
op.drop_constraint('fk_employee_identities_tenant', 'employee_identities', type_='foreignkey')
op.drop_constraint('fk_departments_tenant', 'departments', type_='foreignkey')
op.drop_constraint('fk_business_units_tenant', 'business_units', type_='foreignkey')
op.drop_constraint('fk_employees_tenant', 'employees', type_='foreignkey')
# 移除 tenant_id 欄位
op.drop_column('audit_logs', 'tenant_id')
op.drop_column('network_drives', 'tenant_id')
op.drop_column('employee_identities', 'tenant_id')
op.drop_column('departments', 'tenant_id')
op.drop_column('business_units', 'tenant_id')
op.drop_column('employees', 'tenant_id')
# 移除 tenants 表
op.drop_index('idx_tenants_status', table_name='tenants')
op.drop_index('idx_tenants_code', table_name='tenants')
op.drop_table('tenants')

View File

@@ -0,0 +1,81 @@
"""add email_accounts and permissions tables
Revision ID: 0002
Revises: fba4e3f40f05
Create Date: 2026-02-15 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0002'
down_revision: Union[str, None] = '0001_5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 創建 email_accounts 表
op.create_table(
'email_accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
sa.Column('email_address', sa.String(length=255), nullable=False, comment='郵件地址'),
sa.Column('quota_mb', sa.Integer(), nullable=False, server_default='2048', comment='配額 (MB)'),
sa.Column('forward_to', sa.String(length=255), nullable=True, comment='轉寄地址'),
sa.Column('auto_reply', sa.Text(), nullable=True, comment='自動回覆內容'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
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.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
)
# 創建索引
op.create_index('idx_email_accounts_email', 'email_accounts', ['email_address'], unique=True)
op.create_index('idx_email_accounts_employee', 'email_accounts', ['employee_id'])
op.create_index('idx_email_accounts_tenant', 'email_accounts', ['tenant_id'])
op.create_index('idx_email_accounts_active', 'email_accounts', ['is_active'])
# 創建 permissions 表
op.create_table(
'permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
sa.Column('system_name', sa.String(length=100), nullable=False, comment='系統名稱'),
sa.Column('access_level', sa.String(length=50), nullable=False, server_default='user', comment='存取層級'),
sa.Column('granted_at', sa.DateTime(), nullable=False, server_default=sa.text('now()'), comment='授予時間'),
sa.Column('granted_by', sa.Integer(), nullable=True, comment='授予人'),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['granted_by'], ['employees.id'], ondelete='SET NULL'),
sa.UniqueConstraint('employee_id', 'system_name', name='uq_employee_system'),
)
# 創建索引
op.create_index('idx_permissions_employee', 'permissions', ['employee_id'])
op.create_index('idx_permissions_tenant', 'permissions', ['tenant_id'])
op.create_index('idx_permissions_system', 'permissions', ['system_name'])
def downgrade() -> None:
# 刪除 permissions 表
op.drop_index('idx_permissions_system', table_name='permissions')
op.drop_index('idx_permissions_tenant', table_name='permissions')
op.drop_index('idx_permissions_employee', table_name='permissions')
op.drop_table('permissions')
# 刪除 email_accounts 表
op.drop_index('idx_email_accounts_active', table_name='email_accounts')
op.drop_index('idx_email_accounts_tenant', table_name='email_accounts')
op.drop_index('idx_email_accounts_employee', table_name='email_accounts')
op.drop_index('idx_email_accounts_email', table_name='email_accounts')
op.drop_table('email_accounts')

View File

@@ -0,0 +1,141 @@
"""extend organization structure
Revision ID: 0003
Revises: 0002
Create Date: 2026-02-15 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0003'
down_revision: Union[str, None] = '0002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 擴充 business_units 表
op.add_column('business_units', sa.Column('primary_domain', sa.String(100), nullable=True))
op.add_column('business_units', sa.Column('email_address', sa.String(255), nullable=True))
op.add_column('business_units', sa.Column('email_quota_mb', sa.Integer(), server_default='10240', nullable=False))
# 擴充 departments 表
op.add_column('departments', sa.Column('email_address', sa.String(255), nullable=True))
op.add_column('departments', sa.Column('email_quota_mb', sa.Integer(), server_default='5120', nullable=False))
# 擴充 email_accounts 表 (支援組織/事業部/部門信箱)
op.add_column('email_accounts', sa.Column('account_type', sa.String(20), server_default='personal', nullable=False))
op.add_column('email_accounts', sa.Column('department_id', sa.Integer(), nullable=True))
op.add_column('email_accounts', sa.Column('business_unit_id', sa.Integer(), nullable=True))
# 添加外鍵約束
op.create_foreign_key('fk_email_accounts_department', 'email_accounts', 'departments', ['department_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_email_accounts_business_unit', 'email_accounts', 'business_units', ['business_unit_id'], ['id'], ondelete='CASCADE')
# 更新現有的 business_units 資料 (三大事業部)
op.execute("""
UPDATE business_units SET
primary_domain = 'ease.taipei',
email_address = 'business@ease.taipei'
WHERE name = '業務發展部' OR code = 'BD'
""")
op.execute("""
UPDATE business_units SET
primary_domain = 'lab.taipei',
email_address = 'tech@lab.taipei'
WHERE name = '技術發展部' OR code = 'TD'
""")
op.execute("""
UPDATE business_units SET
primary_domain = 'porscheworld.tw',
email_address = 'operations@porscheworld.tw'
WHERE name = '營運管理部' OR code = 'OM'
""")
# 插入初始部門資料
# 業務發展部 (假設 id=1)
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '玄鐵風能', 'WIND', 'wind@ease.taipei', 5120, NOW()
FROM business_units WHERE name = '業務發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '虛擬公司', 'VIRTUAL', 'virtual@ease.taipei', 5120, NOW()
FROM business_units WHERE name = '業務發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '國際碳權', 'CARBON', 'carbon@ease.taipei', 5120, NOW()
FROM business_units WHERE name = '業務發展部' LIMIT 1
""")
# 技術發展部 (假設 id=2)
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '智能研發', 'AI', 'ai@lab.taipei', 5120, NOW()
FROM business_units WHERE name = '技術發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '軟體開發', 'DEV', 'dev@lab.taipei', 5120, NOW()
FROM business_units WHERE name = '技術發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '虛擬MIS', 'MIS', 'mis@lab.taipei', 5120, NOW()
FROM business_units WHERE name = '技術發展部' LIMIT 1
""")
# 營運管理部 (假設 id=3)
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '人資', 'HR', 'hr@porscheworld.tw', 5120, NOW()
FROM business_units WHERE name = '營運管理部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '財務', 'FIN', 'finance@porscheworld.tw', 5120, NOW()
FROM business_units WHERE name = '營運管理部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '總務', 'ADMIN', 'admin@porscheworld.tw', 5120, NOW()
FROM business_units WHERE name = '營運管理部' LIMIT 1
""")
def downgrade() -> None:
# 移除外鍵約束
op.drop_constraint('fk_email_accounts_business_unit', 'email_accounts', type_='foreignkey')
op.drop_constraint('fk_email_accounts_department', 'email_accounts', type_='foreignkey')
# 移除 email_accounts 擴充欄位
op.drop_column('email_accounts', 'business_unit_id')
op.drop_column('email_accounts', 'department_id')
op.drop_column('email_accounts', 'account_type')
# 移除 departments 擴充欄位
op.drop_column('departments', 'email_quota_mb')
op.drop_column('departments', 'email_address')
# 移除 business_units 擴充欄位
op.drop_column('business_units', 'email_quota_mb')
op.drop_column('business_units', 'email_address')
op.drop_column('business_units', 'primary_domain')
# 刪除部門資料 (downgrade 時)
op.execute("DELETE FROM departments WHERE tenant_id = 1")

View File

@@ -0,0 +1,42 @@
"""add batch_logs table
Revision ID: 0004
Revises: 0003
Create Date: 2026-02-18 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0004'
down_revision: Union[str, None] = '0003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'batch_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('batch_name', sa.String(length=100), nullable=False, comment='批次名稱'),
sa.Column('status', sa.String(length=20), nullable=False, comment='執行狀態: success/failed/warning'),
sa.Column('message', sa.Text(), nullable=True, comment='執行訊息或錯誤詳情'),
sa.Column('started_at', sa.DateTime(), nullable=False, comment='開始時間'),
sa.Column('finished_at', sa.DateTime(), nullable=True, comment='完成時間'),
sa.Column('duration_seconds', sa.Integer(), nullable=True, comment='執行時間 (秒)'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_batch_logs_id', 'batch_logs', ['id'], unique=False)
op.create_index('ix_batch_logs_batch_name', 'batch_logs', ['batch_name'], unique=False)
op.create_index('ix_batch_logs_started_at', 'batch_logs', ['started_at'], unique=False)
def downgrade() -> None:
op.drop_index('ix_batch_logs_started_at', table_name='batch_logs')
op.drop_index('ix_batch_logs_batch_name', table_name='batch_logs')
op.drop_index('ix_batch_logs_id', table_name='batch_logs')
op.drop_table('batch_logs')

View File

@@ -0,0 +1,343 @@
"""multi-tenant architecture refactor
Revision ID: 0005
Revises: 0004
Create Date: 2026-02-19 00:00:00.000000
重構內容:
1. departments 改為統一樹狀結構 (新增 parent_id, email_domain, depth)
2. business_units 資料遷移為 departments 第一層節點,廢棄 business_units 表
3. 新增 department_members 表 (員工多部門歸屬)
4. employee_identities 資料遷移至 department_members標記廢棄
5. employees 新增 keycloak_user_id (唯一 SSO 識別碼)
6. tenants 新增 keycloak_realm (= tenant_code)
7. 新增 RBAC: system_functions_cache, roles, role_rights, user_role_assignments
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0005'
down_revision: Union[str, None] = '0004'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# =========================================================
# A-1: 修改 departments 表 - 新增樹狀結構欄位
# =========================================================
op.add_column('departments', sa.Column('parent_id', sa.Integer(), nullable=True))
op.add_column('departments', sa.Column('email_domain', sa.String(100), nullable=True))
op.add_column('departments', sa.Column('depth', sa.Integer(), server_default='1', nullable=False))
# 新增自我參照外鍵 (parent_id → departments.id)
op.create_foreign_key(
'fk_departments_parent',
'departments', 'departments',
['parent_id'], ['id'],
ondelete='CASCADE'
)
# 新增索引
op.create_index('idx_departments_parent', 'departments', ['parent_id'])
op.create_index('idx_departments_depth', 'departments', ['depth'])
# =========================================================
# A-2: 將 business_units 資料遷移為 departments 第一層節點
# =========================================================
# 先將 business_unit_id 改為 nullable (新插入的第一層節點不需要此欄位)
op.alter_column('departments', 'business_unit_id', nullable=True)
# 先將現有 departments 的 depth 設為 1 (它們都是原本的子部門)
op.execute("UPDATE departments SET depth = 1")
# 將 business_units 插入 departments 為第一層節點 (depth=0, parent_id=NULL)
op.execute("""
INSERT INTO departments (tenant_id, code, name, email_domain, parent_id, depth, is_active, created_at, email_address, email_quota_mb)
SELECT
bu.tenant_id,
bu.code,
bu.name,
bu.email_domain,
NULL,
0,
bu.is_active,
bu.created_at,
bu.email_address,
bu.email_quota_mb
FROM business_units bu
""")
# 更新原有子部門parent_id 指向剛插入的第一層節點
op.execute("""
UPDATE departments AS d
SET parent_id = top.id
FROM departments AS top
JOIN business_units bu ON bu.code = top.code AND bu.tenant_id = top.tenant_id
WHERE d.business_unit_id = bu.id
AND top.depth = 0
AND d.depth = 1
""")
# 更新 unique constraint (移除舊的,建立新的)
op.drop_constraint('uq_department_bu_code', 'departments', type_='unique')
op.create_unique_constraint(
'uq_tenant_parent_dept_code',
'departments',
['tenant_id', 'parent_id', 'code']
)
# 移除 departments.business_unit_id 外鍵和欄位
op.drop_constraint('departments_business_unit_id_fkey', 'departments', type_='foreignkey')
op.drop_index('ix_departments_business_unit_id', table_name='departments')
op.drop_column('departments', 'business_unit_id')
# 重建 tenant 索引 (原名 idx_departments_tenant 已存在,建立新名稱)
op.create_index('idx_dept_tenant_id', 'departments', ['tenant_id'])
# =========================================================
# A-3: 新增 department_members 表
# =========================================================
op.create_table(
'department_members',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
sa.Column('department_id', sa.Integer(), nullable=False, comment='部門 ID'),
sa.Column('position', sa.String(100), nullable=True, comment='在該部門的職稱'),
sa.Column('membership_type', sa.String(50), server_default='permanent', nullable=False,
comment='成員類型: permanent/temporary/project'),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('joined_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('employee_id', 'department_id', name='uq_employee_department'),
)
op.create_index('idx_dept_members_tenant', 'department_members', ['tenant_id'])
op.create_index('idx_dept_members_employee', 'department_members', ['employee_id'])
op.create_index('idx_dept_members_department', 'department_members', ['department_id'])
# =========================================================
# A-4: 遷移 employee_identities → department_members
# =========================================================
op.execute("""
INSERT INTO department_members (tenant_id, employee_id, department_id, position, is_active, joined_at)
SELECT
ei.tenant_id,
ei.employee_id,
ei.department_id,
ei.job_title,
ei.is_active,
COALESCE(ei.started_at, ei.created_at)
FROM employee_identities ei
WHERE ei.department_id IS NOT NULL
ON CONFLICT (employee_id, department_id) DO NOTHING
""")
# 標記 employee_identities 為廢棄
op.add_column('employee_identities', sa.Column(
'deprecated_at', sa.DateTime(),
server_default=sa.text('now()'),
nullable=True,
comment='廢棄標記 - 資料已遷移至 department_members'
))
# =========================================================
# A-5: employees 新增 keycloak_user_id
# =========================================================
op.add_column('employees', sa.Column(
'keycloak_user_id', sa.String(36),
nullable=True,
unique=True,
comment='Keycloak User UUID (唯一 SSO 識別碼,永久不變)'
))
op.create_index('idx_employees_keycloak', 'employees', ['keycloak_user_id'])
# =========================================================
# A-6: tenants 新增 keycloak_realm
# =========================================================
op.add_column('tenants', sa.Column(
'keycloak_realm', sa.String(100),
nullable=True,
unique=True,
comment='Keycloak Realm 名稱 (等同 tenant_code)'
))
op.create_index('idx_tenants_realm', 'tenants', ['keycloak_realm'])
# 為現有租戶設定 keycloak_realm = tenant_code
op.execute("UPDATE tenants SET keycloak_realm = code WHERE keycloak_realm IS NULL")
# =========================================================
# A-7: 新增 RBAC 表
# =========================================================
# system_functions_cache (從 System Admin 同步的系統功能定義)
op.create_table(
'system_functions_cache',
sa.Column('id', sa.Integer(), nullable=False, comment='與 System Admin 的 id 一致'),
sa.Column('service_code', sa.String(50), nullable=False, comment='服務代碼: hr/erp/mail/ai'),
sa.Column('function_code', sa.String(100), nullable=False, comment='功能代碼: HR_EMPLOYEE_VIEW'),
sa.Column('function_name', sa.String(200), nullable=False, comment='功能名稱'),
sa.Column('function_category', sa.String(50), nullable=True, comment='功能分類: query/manage/approve/report'),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('synced_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False,
comment='最後同步時間'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('function_code', name='uq_function_code'),
)
op.create_index('idx_func_cache_service', 'system_functions_cache', ['service_code'])
op.create_index('idx_func_cache_category', 'system_functions_cache', ['function_category'])
# roles (租戶層級角色)
op.create_table(
'roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('role_code', sa.String(100), nullable=False, comment='角色代碼 (租戶內唯一)'),
sa.Column('role_name', sa.String(200), nullable=False, comment='角色名稱'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id', 'role_code', name='uq_tenant_role_code'),
)
op.create_index('idx_roles_tenant', 'roles', ['tenant_id'])
# role_rights (角色 → 系統功能 CRUD 權限)
op.create_table(
'role_rights',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('function_id', sa.Integer(), nullable=False),
sa.Column('can_read', sa.Boolean(), server_default='false', nullable=False),
sa.Column('can_create', sa.Boolean(), server_default='false', nullable=False),
sa.Column('can_update', sa.Boolean(), server_default='false', nullable=False),
sa.Column('can_delete', sa.Boolean(), server_default='false', nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['function_id'], ['system_functions_cache.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('role_id', 'function_id', name='uq_role_function'),
)
op.create_index('idx_role_rights_role', 'role_rights', ['role_id'])
op.create_index('idx_role_rights_function', 'role_rights', ['function_id'])
# user_role_assignments (使用者角色分配,直接對人不對部門)
op.create_table(
'user_role_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('keycloak_user_id', sa.String(36), nullable=False, comment='Keycloak User UUID'),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('assigned_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('assigned_by', sa.String(36), nullable=True, comment='分配者 keycloak_user_id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('keycloak_user_id', 'role_id', name='uq_user_role'),
)
op.create_index('idx_user_roles_tenant', 'user_role_assignments', ['tenant_id'])
op.create_index('idx_user_roles_keycloak', 'user_role_assignments', ['keycloak_user_id'])
# =========================================================
# A-8: Seed data - HR 系統功能 + 初始角色
# =========================================================
op.execute("""
INSERT INTO system_functions_cache (id, service_code, function_code, function_name, function_category) VALUES
(1, 'hr', 'HR_EMPLOYEE_VIEW', '員工查詢', 'query'),
(2, 'hr', 'HR_EMPLOYEE_MANAGE', '員工管理', 'manage'),
(3, 'hr', 'HR_DEPT_MANAGE', '部門管理', 'manage'),
(4, 'hr', 'HR_ROLE_MANAGE', '角色管理', 'manage'),
(5, 'hr', 'HR_AUDIT_VIEW', '審計日誌查詢', 'query')
""")
op.execute("""
INSERT INTO roles (tenant_id, role_code, role_name, description) VALUES
(1, 'HR_ADMIN', '人資管理員', '可管理員工資料、組織架構、角色分配'),
(1, 'HR_VIEWER', '人資查詢者', '只能查詢員工資料'),
(1, 'SYSTEM_ADMIN', '系統管理員', '擁有所有 HR 系統功能權限')
""")
# HR_ADMIN 擁有所有 HR 功能的完整權限
op.execute("""
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
SELECT r.id, f.id,
true,
CASE WHEN f.function_category = 'manage' THEN true ELSE false END,
CASE WHEN f.function_category IN ('manage', 'approve') THEN true ELSE false END,
CASE WHEN f.function_category = 'manage' THEN true ELSE false END
FROM roles r, system_functions_cache f
WHERE r.role_code = 'HR_ADMIN' AND r.tenant_id = 1
""")
# HR_VIEWER 只有查詢權限
op.execute("""
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
SELECT r.id, f.id, true, false, false, false
FROM roles r, system_functions_cache f
WHERE r.role_code = 'HR_VIEWER' AND r.tenant_id = 1
AND f.function_category = 'query'
""")
# SYSTEM_ADMIN 擁有所有功能的完整 CRUD
op.execute("""
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
SELECT r.id, f.id, true, true, true, true
FROM roles r, system_functions_cache f
WHERE r.role_code = 'SYSTEM_ADMIN' AND r.tenant_id = 1
""")
def downgrade() -> None:
# 移除 RBAC 表
op.drop_table('user_role_assignments')
op.drop_table('role_rights')
op.drop_table('roles')
op.drop_table('system_functions_cache')
# 移除 keycloak 欄位
op.drop_index('idx_tenants_realm', table_name='tenants')
op.drop_column('tenants', 'keycloak_realm')
op.drop_index('idx_employees_keycloak', table_name='employees')
op.drop_column('employees', 'keycloak_user_id')
# 移除廢棄標記
op.drop_column('employee_identities', 'deprecated_at')
# 移除 department_members
op.drop_table('department_members')
# 還原 departments (重新加回 business_unit_id) - 注意: 資料無法完全還原
op.add_column('departments', sa.Column('business_unit_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_departments_business_unit',
'departments', 'business_units',
['business_unit_id'], ['id'],
ondelete='CASCADE'
)
op.drop_constraint('uq_tenant_parent_dept_code', 'departments', type_='unique')
op.create_unique_constraint(
'uq_tenant_bu_dept_code',
'departments',
['tenant_id', 'business_unit_id', 'code']
)
op.drop_index('idx_dept_tenant_id', table_name='departments')
op.drop_index('idx_departments_parent', table_name='departments')
op.drop_index('idx_departments_depth', table_name='departments')
op.drop_constraint('fk_departments_parent', 'departments', type_='foreignkey')
op.drop_column('departments', 'parent_id')
op.drop_column('departments', 'email_domain')
op.drop_column('departments', 'depth')
op.create_index('idx_dept_tenant', 'departments', ['tenant_id'])

View File

@@ -0,0 +1,28 @@
"""add name_en to departments
Revision ID: 0006
Revises: 0005
Create Date: 2026-02-19 09:50:17.913124
補充 migration 0005 遺漏的欄位:
- departments.name_en (英文名稱)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0006'
down_revision: Union[str, None] = '0005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('departments', sa.Column('name_en', sa.String(100), nullable=True, comment='英文名稱'))
def downgrade() -> None:
op.drop_column('departments', 'name_en')

View File

@@ -0,0 +1,77 @@
"""rename tables to match org schema
Revision ID: 0007
Revises: 0006
Create Date: 2026-02-20 12:00:33.776853
重新命名資料表以符合組織架構規格:
- tenants → organizes
- departments → org_departments
- roles → org_user_roles
- employees → org_employees
- department_members → org_dept_members
- user_role_assignments → org_user_role_assignments
- role_rights → org_role_rights
- email_accounts → org_email_accounts
- network_drives → org_network_drives
- permissions → org_permissions
- audit_logs → org_audit_logs
- batch_logs → org_batch_logs
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0007'
down_revision: Union[str, None] = '0006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# =========================================================
# 重新命名資料表(按照依賴順序,從子表到父表)
# =========================================================
# 1. 批次作業和日誌表(無外鍵依賴)
op.rename_table('batch_logs', 'org_batch_logs')
op.rename_table('audit_logs', 'org_audit_logs')
# 2. 員工相關子表(依賴 employees
op.rename_table('permissions', 'org_permissions')
op.rename_table('network_drives', 'org_network_drives')
op.rename_table('email_accounts', 'org_email_accounts')
# 3. 角色權限相關(依賴 roles
op.rename_table('role_rights', 'org_role_rights')
op.rename_table('user_role_assignments', 'org_user_role_assignments')
# 4. 部門成員(依賴 departments, employees
op.rename_table('department_members', 'org_dept_members')
# 5. 主要業務表
op.rename_table('roles', 'org_user_roles')
op.rename_table('departments', 'org_departments')
op.rename_table('employees', 'org_employees')
# 6. 租戶/組織表(最後,因為其他表都依賴它)
op.rename_table('tenants', 'organizes')
def downgrade() -> None:
# 反向操作:從父表到子表
op.rename_table('organizes', 'tenants')
op.rename_table('org_employees', 'employees')
op.rename_table('org_departments', 'departments')
op.rename_table('org_user_roles', 'roles')
op.rename_table('org_dept_members', 'department_members')
op.rename_table('org_user_role_assignments', 'user_role_assignments')
op.rename_table('org_role_rights', 'role_rights')
op.rename_table('org_email_accounts', 'email_accounts')
op.rename_table('org_network_drives', 'network_drives')
op.rename_table('org_permissions', 'permissions')
op.rename_table('org_audit_logs', 'audit_logs')
op.rename_table('org_batch_logs', 'batch_logs')

View File

@@ -0,0 +1,71 @@
"""add is_active to all tables
Revision ID: 0008
Revises: 0007
Create Date: 2026-02-20
統一為所有資料表新增 is_active 布林欄位
- true: 資料啟用
- false: 資料不啟用
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0008'
down_revision = '0007'
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. organizes (目前只有 status新增 is_active預設從 status 轉換)
op.add_column('organizes', sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否啟用'))
op.execute("""
UPDATE organizes
SET is_active = CASE
WHEN status = 'active' THEN true
WHEN status = 'trial' THEN true
ELSE false
END
""")
op.alter_column('organizes', 'is_active', nullable=False)
# 2. org_employees (目前只有 status新增 is_active)
op.add_column('org_employees', sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否啟用'))
op.execute("""
UPDATE org_employees
SET is_active = CASE
WHEN status = 'active' THEN true
ELSE false
END
""")
op.alter_column('org_employees', 'is_active', nullable=False)
# 3. org_batch_logs (目前只有 status但這是執行狀態不是啟用狀態仍新增 is_active)
op.add_column('org_batch_logs', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'))
# 4. 其他已有 is_active 的表不需處理
# - org_departments (已有 is_active)
# - business_units (已有 is_active)
# - org_dept_members (已有 is_active)
# - org_email_accounts (已有 is_active)
# - org_network_drives (已有 is_active)
# - org_user_roles (已有 is_active)
# - org_user_role_assignments (已有 is_active)
# - system_functions_cache (已有 is_active)
# 5. 檢查其他表是否需要 is_active (根據業務邏輯)
# - org_audit_logs: 審計日誌不需要 is_active (不可停用)
# - org_permissions: 已有 is_active (透過 ended_at 判斷)
#
# 注意: tenant_domains, subscriptions, invoices, payments 等表可能不存在於目前的資料庫
# 這些表屬於多租戶進階功能,將在後續 migration 中建立
# 暫時跳過這些表的 is_active 處理
def downgrade() -> None:
op.drop_column('org_batch_logs', 'is_active')
op.drop_column('org_employees', 'is_active')
op.drop_column('organizes', 'is_active')

View File

@@ -0,0 +1,144 @@
"""add tenant scoped sequences
Revision ID: 0009
Revises: 0008
Create Date: 2026-02-20
為每個租戶建立獨立的序號生成器
- 每個租戶的資料從 1 開始編號
- 保證租戶內序號連續性
"""
from alembic import op
import sqlalchemy as sa
revision = '0009'
down_revision = '0008'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# 方案:為主要實體表新增租戶內序號欄位
# =========================================================
# 1. org_employees: 新增 seq_no (租戶內序號)
op.add_column('org_employees', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
# 為現有資料填充 seq_no
op.execute("""
WITH numbered AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
FROM org_employees
)
UPDATE org_employees
SET seq_no = numbered.seq
FROM numbered
WHERE org_employees.id = numbered.id
""")
# 設為 NOT NULL
op.alter_column('org_employees', 'seq_no', nullable=False)
# 建立唯一索引tenant_id + seq_no
op.create_index(
'idx_org_employees_tenant_seq',
'org_employees',
['tenant_id', 'seq_no'],
unique=True
)
# 2. org_departments: 新增 seq_no
op.add_column('org_departments', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
op.execute("""
WITH numbered AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
FROM org_departments
)
UPDATE org_departments
SET seq_no = numbered.seq
FROM numbered
WHERE org_departments.id = numbered.id
""")
op.alter_column('org_departments', 'seq_no', nullable=False)
op.create_index(
'idx_org_departments_tenant_seq',
'org_departments',
['tenant_id', 'seq_no'],
unique=True
)
# 3. org_user_roles: 新增 seq_no
op.add_column('org_user_roles', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
op.execute("""
WITH numbered AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
FROM org_user_roles
)
UPDATE org_user_roles
SET seq_no = numbered.seq
FROM numbered
WHERE org_user_roles.id = numbered.id
""")
op.alter_column('org_user_roles', 'seq_no', nullable=False)
op.create_index(
'idx_org_user_roles_tenant_seq',
'org_user_roles',
['tenant_id', 'seq_no'],
unique=True
)
# 4. 建立自動生成序號的觸發器函數
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_seq_no()
RETURNS TRIGGER AS $$
BEGIN
-- 如果 seq_no 未提供,自動生成
IF NEW.seq_no IS NULL THEN
SELECT COALESCE(MAX(seq_no), 0) + 1
INTO NEW.seq_no
FROM org_employees
WHERE tenant_id = NEW.tenant_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""")
# 5. 為各表建立觸發器
for table_name in ['org_employees', 'org_departments', 'org_user_roles']:
trigger_name = f'trigger_{table_name}_seq_no'
op.execute(f"""
CREATE TRIGGER {trigger_name}
BEFORE INSERT ON {table_name}
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
def downgrade() -> None:
# 移除觸發器
for table_name in ['org_employees', 'org_departments', 'org_user_roles']:
trigger_name = f'trigger_{table_name}_seq_no'
op.execute(f"DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}")
# 移除函數
op.execute("DROP FUNCTION IF EXISTS generate_tenant_seq_no()")
# 移除索引和欄位
op.drop_index('idx_org_user_roles_tenant_seq', table_name='org_user_roles')
op.drop_column('org_user_roles', 'seq_no')
op.drop_index('idx_org_departments_tenant_seq', table_name='org_departments')
op.drop_column('org_departments', 'seq_no')
op.drop_index('idx_org_employees_tenant_seq', table_name='org_employees')
op.drop_column('org_employees', 'seq_no')

View File

@@ -0,0 +1,341 @@
"""refactor to final architecture
Revision ID: 0010
Revises: 0009
Create Date: 2026-02-20
完整架構重構:
1. 統一表名前綴org_* → tenant_*organizes → tenants
2. 統一通用欄位is_active, edit_by, created_at, updated_at
3. 擴充 tenants 表業務欄位
4. 新增 personal_services 表
5. 新增 tenant_emp_resumes 表(人員基本資料)
6. 新增 tenant_emp_setting 表(員工任用設定,使用複合主鍵)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = '0010'
down_revision = '0009'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# Phase 1: 統一表名前綴org_* → tenant_*
# =========================================================
# 1.1 核心表
op.rename_table('organizes', 'tenants')
op.rename_table('org_departments', 'tenant_departments')
op.rename_table('org_employees', 'tenant_employees')
op.rename_table('org_user_roles', 'tenant_user_roles')
# 1.2 關聯表
op.rename_table('org_dept_members', 'tenant_dept_members')
op.rename_table('org_email_accounts', 'tenant_email_accounts')
op.rename_table('org_network_drives', 'tenant_network_drives')
op.rename_table('org_permissions', 'tenant_permissions')
op.rename_table('org_role_rights', 'tenant_role_rights')
op.rename_table('org_user_role_assignments', 'tenant_user_role_assignments')
# 1.3 系統表
op.rename_table('org_audit_logs', 'tenant_audit_logs')
op.rename_table('org_batch_logs', 'tenant_batch_logs')
# =========================================================
# Phase 2: 統一通用欄位(所有表)
# =========================================================
# 需要更新的所有表(包含新命名的)
tables_to_update = [
'business_units',
'employee_identities',
'system_functions_cache',
'tenant_audit_logs',
'tenant_batch_logs',
'tenant_departments',
'tenant_dept_members',
'tenant_email_accounts',
'tenant_employees',
'tenant_network_drives',
'tenant_permissions',
'tenant_role_rights',
'tenant_user_role_assignments',
'tenant_user_roles',
'tenants',
]
for table_name in tables_to_update:
# 2.1 新增 edit_by使用 op.add_column安全處理已存在情況
try:
op.add_column(table_name, sa.Column('edit_by', sa.String(100), comment='資料編輯者'))
except:
pass # 已存在則跳過
# 2.2 確保有 created_at
# 先檢查是否有 created_at 或 create_at
result = op.get_bind().execute(sa.text(f"""
SELECT column_name FROM information_schema.columns
WHERE table_name = '{table_name}'
AND column_name IN ('created_at', 'create_at')
"""))
existing_cols = [row[0] for row in result]
if 'create_at' in existing_cols and 'created_at' not in existing_cols:
op.alter_column(table_name, 'create_at', new_column_name='created_at')
elif not existing_cols:
op.add_column(table_name, sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')))
# 2.3 確保有 updated_at
result2 = op.get_bind().execute(sa.text(f"""
SELECT column_name FROM information_schema.columns
WHERE table_name = '{table_name}'
AND column_name IN ('updated_at', 'edit_at')
"""))
existing_cols2 = [row[0] for row in result2]
if 'edit_at' in existing_cols2 and 'updated_at' not in existing_cols2:
op.alter_column(table_name, 'edit_at', new_column_name='updated_at')
elif not existing_cols2:
op.add_column(table_name, sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')))
# =========================================================
# Phase 3: 擴充 tenants 表
# =========================================================
# 3.1 移除重複欄位
op.drop_column('tenants', 'keycloak_realm') # 與 code 重複
# 3.2 欄位已在 migration 0007 重新命名,跳過
# tenant_code → code (已完成)
# company_name → name (已完成)
# 3.3 新增業務欄位
op.add_column('tenants', sa.Column('name_eng', sa.String(200), comment='公司英文名稱'))
op.add_column('tenants', sa.Column('tax_id', sa.String(20), comment='公司統編'))
op.add_column('tenants', sa.Column('prefix', sa.String(10), nullable=False, server_default='ORG', comment='公司簡稱(員工編號前綴)'))
op.add_column('tenants', sa.Column('domain_set', sa.Integer, nullable=False, server_default='2', comment='網域條件1=組織網域2=部門網域'))
op.add_column('tenants', sa.Column('domain', sa.String(100), comment='組織網域domain_set=1時使用'))
op.add_column('tenants', sa.Column('tel', sa.String(20), comment='公司代表號'))
op.add_column('tenants', sa.Column('add', sa.Text, comment='公司登記地址'))
op.add_column('tenants', sa.Column('fax', sa.String(20), comment='公司傳真電話'))
op.add_column('tenants', sa.Column('contact', sa.String(100), comment='公司聯絡人'))
op.add_column('tenants', sa.Column('contact_email', sa.String(255), comment='公司聯絡人電子郵件'))
op.add_column('tenants', sa.Column('contact_mobil', sa.String(20), comment='公司聯絡人行動電話'))
op.add_column('tenants', sa.Column('is_sysmana', sa.Boolean, nullable=False, server_default='false', comment='是否為系統管理公司'))
op.add_column('tenants', sa.Column('quota', sa.Integer, nullable=False, server_default='100', comment='組織配額GB'))
# 3.4 更新現有租戶 1 的資料
op.execute("""
UPDATE tenants
SET
name_eng = 'Porscheworld',
tax_id = '82871784',
prefix = 'PWD',
domain_set = 2,
tel = '0226262026',
add = '新北市淡水區北新路197號7樓',
contact = 'porsche.chen',
contact_email = 'porsche.chen@gmail.com',
contact_mobil = '0910326333',
is_sysmana = true,
quota = 100
WHERE id = 1
""")
# =========================================================
# Phase 4: 新增 personal_services 表
# =========================================================
op.create_table(
'personal_services',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('service_name', sa.String(100), nullable=False, comment='服務名稱'),
sa.Column('service_code', sa.String(50), unique=True, nullable=False, comment='服務代碼'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
sa.Column('edit_by', sa.String(100), comment='資料編輯者'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
)
# 插入預設資料
op.execute("""
INSERT INTO personal_services (service_name, service_code, is_active) VALUES
('單一簽入', 'SSO', true),
('電子郵件', 'Email', true),
('日曆', 'Calendar', true),
('網路硬碟', 'Drive', true),
('線上office工具', 'Office', true)
""")
# =========================================================
# Phase 5: 新增 tenant_emp_resumes 表(人員基本資料)
# =========================================================
op.create_table(
'tenant_emp_resumes',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('seq_no', sa.Integer(), nullable=False, comment='租戶內序號'),
sa.Column('name_tw', sa.String(100), nullable=False, comment='中文姓名'),
sa.Column('name_eng', sa.String(100), comment='英文姓名'),
sa.Column('personal_id', sa.String(20), comment='身份證號/護照號碼'),
sa.Column('personal_tel', sa.String(20), comment='通訊電話'),
sa.Column('personal_add', sa.Text, comment='通訊地址'),
sa.Column('emergency_contact', sa.String(100), comment='緊急聯絡人'),
sa.Column('emergency_tel', sa.String(20), comment='緊急聯絡人電話'),
sa.Column('academic_qualification', sa.String(200), comment='最高學歷'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('edit_by', sa.String(100)),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.UniqueConstraint('tenant_id', 'seq_no', name='uq_tenant_resume_seq'),
)
# 建立序號觸發器
op.execute("""
CREATE TRIGGER trigger_tenant_emp_resumes_seq_no
BEFORE INSERT ON tenant_emp_resumes
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
# =========================================================
# Phase 6: 新增 tenant_emp_setting 表(員工任用設定)
# 使用複合主鍵:(tenant_id, seq_no)
# =========================================================
op.create_table(
'tenant_emp_setting',
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, comment='租戶 ID'),
sa.Column('seq_no', sa.Integer(), nullable=False, comment='租戶內序號'),
sa.Column('tenant_resume_id', sa.Integer(), sa.ForeignKey('tenant_emp_resumes.id', ondelete='CASCADE'), nullable=False, comment='人員基本資料 ID'),
sa.Column('tenant_emp_code', sa.String(20), nullable=False, comment='員工工號自動生成prefix + seq_no'),
sa.Column('hire_at', sa.Date(), nullable=False, comment='到職日期'),
sa.Column('tenant_dep_ids', postgresql.ARRAY(sa.Integer()), comment='部門設定(陣列)'),
sa.Column('tenant_user_roles_ids', postgresql.ARRAY(sa.Integer()), comment='使用者角色(陣列)'),
sa.Column('tenant_user_quota', sa.Integer(), nullable=False, server_default='20', comment='配額設定GB'),
sa.Column('tenant_user_personal_services', postgresql.ARRAY(sa.Integer()), comment='個人化服務設定(陣列)'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('edit_by', sa.String(100)),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
# 複合主鍵
sa.PrimaryKeyConstraint('tenant_id', 'seq_no', name='pk_tenant_emp_setting'),
# 其他唯一約束
sa.UniqueConstraint('tenant_id', 'tenant_emp_code', name='uq_tenant_emp_code'),
sa.UniqueConstraint('tenant_id', 'tenant_resume_id', name='uq_tenant_resume'), # 一人一筆任用檔
)
# 建立索引
op.create_index('idx_tenant_emp_setting_tenant', 'tenant_emp_setting', ['tenant_id'])
op.create_index('idx_tenant_emp_setting_resume', 'tenant_emp_setting', ['tenant_resume_id'])
# 建立序號觸發器(針對複合主鍵表調整)
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_emp_setting_seq()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.seq_no IS NULL THEN
SELECT COALESCE(MAX(seq_no), 0) + 1
INTO NEW.seq_no
FROM tenant_emp_setting
WHERE tenant_id = NEW.tenant_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_tenant_emp_setting_seq_no
BEFORE INSERT ON tenant_emp_setting
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
""")
# 建立員工工號自動生成觸發器
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_emp_code()
RETURNS TRIGGER AS $$
DECLARE
tenant_prefix VARCHAR(10);
BEGIN
IF NEW.tenant_emp_code IS NULL OR NEW.tenant_emp_code = '' THEN
-- 取得租戶的 prefix
SELECT prefix INTO tenant_prefix
FROM tenants
WHERE id = NEW.tenant_id;
-- 生成工號prefix + LPAD(seq_no, 4, '0')
-- 例如PWD + 0001 = PWD0001
NEW.tenant_emp_code := tenant_prefix || LPAD(NEW.seq_no::TEXT, 4, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_generate_emp_code
BEFORE INSERT ON tenant_emp_setting
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_code();
""")
# =========================================================
# Phase 7: 更新現有觸發器函數(支援新表)
# =========================================================
# 更新原有的 generate_tenant_seq_no 函數,使其支援不同表
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_seq_no()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.seq_no IS NULL THEN
-- 動態根據 TG_TABLE_NAME 決定查詢哪個表
EXECUTE format('
SELECT COALESCE(MAX(seq_no), 0) + 1
FROM %I
WHERE tenant_id = $1
', TG_TABLE_NAME)
INTO NEW.seq_no
USING NEW.tenant_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""")
def downgrade() -> None:
# 移除新建的觸發器和函數
op.execute("DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_setting")
op.execute("DROP FUNCTION IF EXISTS generate_tenant_emp_code()")
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_emp_setting_seq_no ON tenant_emp_setting")
op.execute("DROP FUNCTION IF EXISTS generate_tenant_emp_setting_seq()")
op.drop_index('idx_tenant_emp_setting_resume', table_name='tenant_emp_setting')
op.drop_index('idx_tenant_emp_setting_tenant', table_name='tenant_emp_setting')
op.drop_table('tenant_emp_setting')
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_emp_resumes_seq_no ON tenant_emp_resumes")
op.drop_table('tenant_emp_resumes')
op.drop_table('personal_services')
# 恢復表名tenant_* → org_*
op.rename_table('tenant_batch_logs', 'org_batch_logs')
op.rename_table('tenant_audit_logs', 'org_audit_logs')
op.rename_table('tenant_user_role_assignments', 'org_user_role_assignments')
op.rename_table('tenant_role_rights', 'org_role_rights')
op.rename_table('tenant_permissions', 'org_permissions')
op.rename_table('tenant_network_drives', 'org_network_drives')
op.rename_table('tenant_email_accounts', 'org_email_accounts')
op.rename_table('tenant_dept_members', 'org_dept_members')
op.rename_table('tenant_user_roles', 'org_user_roles')
op.rename_table('tenant_employees', 'org_employees')
op.rename_table('tenant_departments', 'org_departments')
op.rename_table('tenants', 'organizes')

View File

@@ -0,0 +1,115 @@
"""cleanup and finalize
Revision ID: 0011
Revises: 0010
Create Date: 2026-02-20
架構收尾與清理:
1. 移除廢棄表business_units, employee_identities
2. 為 tenant_role_rights 新增 is_active
3. 重新命名觸發器org_* → tenant_*
"""
from alembic import op
import sqlalchemy as sa
revision = '0011'
down_revision = '0010'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# Phase 1: 移除廢棄表(先移除外鍵約束)
# =========================================================
# 1.1 先移除依賴 business_units 的外鍵
# employee_identities.business_unit_id FK
op.drop_constraint('employee_identities_business_unit_id_fkey', 'employee_identities', type_='foreignkey')
# tenant_email_accounts.business_unit_id FK如果存在
try:
op.drop_constraint('fk_email_accounts_business_unit', 'tenant_email_accounts', type_='foreignkey')
except:
pass # 可能不存在
# 1.2 移除 employee_identities 表(已被 tenant_emp_setting 取代)
op.drop_table('employee_identities')
# 1.3 移除 business_units 表(已被 tenant_departments 取代)
op.drop_table('business_units')
# =========================================================
# Phase 2: 為 tenant_role_rights 新增 is_active
# =========================================================
op.add_column('tenant_role_rights', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'))
# =========================================================
# Phase 3: 重新命名觸發器org_* → tenant_*
# =========================================================
# 3.1 tenant_departments
op.execute("""
DROP TRIGGER IF EXISTS trigger_org_departments_seq_no ON tenant_departments;
CREATE TRIGGER trigger_tenant_departments_seq_no
BEFORE INSERT ON tenant_departments
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
# 3.2 tenant_employees
op.execute("""
DROP TRIGGER IF EXISTS trigger_org_employees_seq_no ON tenant_employees;
CREATE TRIGGER trigger_tenant_employees_seq_no
BEFORE INSERT ON tenant_employees
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
# 3.3 tenant_user_roles
op.execute("""
DROP TRIGGER IF EXISTS trigger_org_user_roles_seq_no ON tenant_user_roles;
CREATE TRIGGER trigger_tenant_user_roles_seq_no
BEFORE INSERT ON tenant_user_roles
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
def downgrade() -> None:
# 恢復觸發器名稱
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_user_roles_seq_no ON tenant_user_roles")
op.execute("""
CREATE TRIGGER trigger_org_user_roles_seq_no
BEFORE INSERT ON tenant_user_roles
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no()
""")
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_employees_seq_no ON tenant_employees")
op.execute("""
CREATE TRIGGER trigger_org_employees_seq_no
BEFORE INSERT ON tenant_employees
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no()
""")
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_departments_seq_no ON tenant_departments")
op.execute("""
CREATE TRIGGER trigger_org_departments_seq_no
BEFORE INSERT ON tenant_departments
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no()
""")
# 移除 is_active
op.drop_column('tenant_role_rights', 'is_active')
# 恢復廢棄表不實現downgrade 不支援重建複雜資料)
# op.create_table('employee_identities', ...)
# op.create_table('business_units', ...)

View File

@@ -0,0 +1,192 @@
"""refactor emp settings to relational
Revision ID: 0012
Revises: 0011
Create Date: 2026-02-20
調整 tenant_emp_setting 表結構:
1. 表名改為複數tenant_emp_setting → tenant_emp_settings
2. 移除 ARRAY 欄位(改用關聯表)
3. 新增 Keycloak 欄位
4. 調整配額欄位命名
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = '0012'
down_revision = '0011'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# Phase 1: 重新命名表(單數 → 複數)
# =========================================================
op.rename_table('tenant_emp_setting', 'tenant_emp_settings')
# =========================================================
# Phase 2: 新增 Keycloak 欄位
# =========================================================
op.add_column('tenant_emp_settings', sa.Column(
'tenant_keycloak_user_id',
sa.String(36),
nullable=True, # 先設為 nullable稍後更新資料後改為 NOT NULL
comment='Keycloak User UUID'
))
op.add_column('tenant_emp_settings', sa.Column(
'tenant_keycloak_username',
sa.String(100),
nullable=True,
comment='Keycloak 使用者帳號'
))
# =========================================================
# Phase 3: 新增郵件配額欄位
# =========================================================
op.add_column('tenant_emp_settings', sa.Column(
'email_quota_mb',
sa.Integer(),
nullable=False,
server_default='5120',
comment='郵件配額 (MB)'
))
# =========================================================
# Phase 4: 重新命名配額欄位
# =========================================================
op.alter_column('tenant_emp_settings', 'tenant_user_quota',
new_column_name='storage_quota_gb')
# =========================================================
# Phase 5: 移除 ARRAY 欄位(改用關聯表)
# =========================================================
op.drop_column('tenant_emp_settings', 'tenant_dep_ids')
op.drop_column('tenant_emp_settings', 'tenant_user_roles_ids')
op.drop_column('tenant_emp_settings', 'tenant_user_personal_services')
# =========================================================
# Phase 6: 更新觸發器名稱(配合表名變更)
# =========================================================
op.execute("""
DROP TRIGGER IF EXISTS trigger_tenant_emp_setting_seq_no ON tenant_emp_settings;
DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_settings;
CREATE TRIGGER trigger_tenant_emp_settings_seq_no
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
CREATE TRIGGER trigger_generate_emp_code
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_code();
""")
# =========================================================
# Phase 7: 更新索引名稱(配合表名變更)
# =========================================================
op.execute("""
ALTER INDEX IF EXISTS idx_tenant_emp_setting_tenant
RENAME TO idx_tenant_emp_settings_tenant;
ALTER INDEX IF EXISTS idx_tenant_emp_setting_resume
RENAME TO idx_tenant_emp_settings_resume;
""")
# =========================================================
# Phase 8: 建立 tenant_emp_personal_service_settings 表(如果不存在)
# =========================================================
# 檢查表是否存在
connection = op.get_bind()
result = connection.execute(sa.text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'tenant_emp_personal_service_settings'
);
"""))
table_exists = result.fetchone()[0]
if not table_exists:
op.create_table(
'tenant_emp_personal_service_settings',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'),
nullable=False, comment='租戶 ID'),
sa.Column('tenant_keycloak_user_id', sa.String(36), nullable=False,
comment='Keycloak User UUID'),
sa.Column('service_id', sa.Integer(), sa.ForeignKey('personal_services.id', ondelete='CASCADE'),
nullable=False, comment='個人化服務 ID'),
sa.Column('quota_gb', sa.Integer(), nullable=True, comment='儲存配額 (GB),適用於 Drive'),
sa.Column('quota_mb', sa.Integer(), nullable=True, comment='郵件配額 (MB),適用於 Email'),
sa.Column('enabled_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'),
comment='啟用時間'),
sa.Column('enabled_by', sa.String(36), nullable=True, comment='啟用者 keycloak_user_id'),
sa.Column('disabled_at', sa.DateTime(), nullable=True, comment='停用時間(軟刪除)'),
sa.Column('disabled_by', sa.String(36), nullable=True, comment='停用者 keycloak_user_id'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
sa.Column('edit_by', sa.String(36), nullable=True, comment='最後編輯者 keycloak_user_id'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.UniqueConstraint('tenant_id', 'tenant_keycloak_user_id', 'service_id', name='uq_emp_service'),
sa.Index('idx_emp_service_tenant', 'tenant_id'),
sa.Index('idx_emp_service_user', 'tenant_keycloak_user_id'),
sa.Index('idx_emp_service_service', 'service_id'),
)
# =========================================================
# Phase 9: 更新現有觸發器支援 tenant_keycloak_user_id
# =========================================================
# DepartmentMember 已經使用 employee_id不需要調整
# UserRoleAssignment 已經使用 keycloak_user_id不需要調整
def downgrade() -> None:
# 恢復 ARRAY 欄位
op.add_column('tenant_emp_settings', sa.Column('tenant_dep_ids', postgresql.ARRAY(sa.Integer()), comment='部門設定(陣列)'))
op.add_column('tenant_emp_settings', sa.Column('tenant_user_roles_ids', postgresql.ARRAY(sa.Integer()), comment='使用者角色(陣列)'))
op.add_column('tenant_emp_settings', sa.Column('tenant_user_personal_services', postgresql.ARRAY(sa.Integer()), comment='個人化服務設定(陣列)'))
# 恢復配額欄位名稱
op.alter_column('tenant_emp_settings', 'storage_quota_gb', new_column_name='tenant_user_quota')
# 移除郵件配額欄位
op.drop_column('tenant_emp_settings', 'email_quota_mb')
# 移除 Keycloak 欄位
op.drop_column('tenant_emp_settings', 'tenant_keycloak_username')
op.drop_column('tenant_emp_settings', 'tenant_keycloak_user_id')
# 恢復索引名稱
op.execute("""
ALTER INDEX IF EXISTS idx_tenant_emp_settings_tenant
RENAME TO idx_tenant_emp_setting_tenant;
ALTER INDEX IF EXISTS idx_tenant_emp_settings_resume
RENAME TO idx_tenant_emp_setting_resume;
""")
# 恢復觸發器名稱
op.execute("""
DROP TRIGGER IF EXISTS trigger_tenant_emp_settings_seq_no ON tenant_emp_settings;
DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_settings;
CREATE TRIGGER trigger_tenant_emp_setting_seq_no
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
CREATE TRIGGER trigger_generate_emp_code
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_code();
""")
# 恢復表名
op.rename_table('tenant_emp_settings', 'tenant_emp_setting')
# 移除 tenant_emp_personal_service_settings 表(如果是這個 migration 建立的)
# 為了安全downgrade 不刪除此表

View File

@@ -0,0 +1,53 @@
"""add tenant initialization fields
Revision ID: 0013
Revises: 0526fc6e6496
Create Date: 2026-02-21
新增租戶初始化相關欄位:
1. is_initialized - 租戶是否已完成初始化
2. initialized_at - 初始化完成時間
3. initialized_by - 執行初始化的使用者名稱
"""
from alembic import op
import sqlalchemy as sa
revision = '0013'
down_revision = '0526fc6e6496' # 基於最新的 migration
branch_labels = None
depends_on = None
def upgrade() -> None:
# 新增初始化狀態欄位
op.add_column('tenants', sa.Column(
'is_initialized',
sa.Boolean(),
nullable=False,
server_default='false',
comment='是否已完成初始化設定'
))
# 新增初始化時間欄位
op.add_column('tenants', sa.Column(
'initialized_at',
sa.DateTime(),
nullable=True,
comment='初始化完成時間'
))
# 新增初始化執行者欄位
op.add_column('tenants', sa.Column(
'initialized_by',
sa.String(255),
nullable=True,
comment='執行初始化的使用者名稱'
))
def downgrade() -> None:
# 移除初始化欄位
op.drop_column('tenants', 'initialized_by')
op.drop_column('tenants', 'initialized_at')
op.drop_column('tenants', 'is_initialized')

View File

@@ -0,0 +1,347 @@
"""add installation system tables
Revision ID: 0014
Revises: 0013
Create Date: 2026-02-22 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0014'
down_revision = '0013'
branch_labels = None
depends_on = None
def upgrade():
"""建立初始化系統相關資料表"""
# 1. installation_sessions (安裝會話)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL初始化時可能還沒有 tenant
sa.Column('session_name', sa.String(200), nullable=True),
sa.Column('environment', sa.String(20), nullable=True),
# 狀態追蹤
sa.Column('started_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('status', sa.String(20), server_default='in_progress'),
# 進度統計
sa.Column('total_checklist_items', sa.Integer(), nullable=True),
sa.Column('passed_checklist_items', sa.Integer(), server_default='0'),
sa.Column('failed_checklist_items', sa.Integer(), server_default='0'),
sa.Column('total_steps', sa.Integer(), nullable=True),
sa.Column('completed_steps', sa.Integer(), server_default='0'),
sa.Column('failed_steps', sa.Integer(), server_default='0'),
sa.Column('executed_by', sa.String(100), nullable=True),
# 存取控制
sa.Column('is_locked', sa.Boolean(), server_default='false'),
sa.Column('locked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('locked_by', sa.String(100), nullable=True),
sa.Column('lock_reason', sa.String(200), nullable=True),
sa.Column('is_unlocked', sa.Boolean(), server_default='false'),
sa.Column('unlocked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('unlocked_by', sa.String(100), nullable=True),
sa.Column('unlock_reason', sa.String(200), nullable=True),
sa.Column('unlock_expires_at', sa.TIMESTAMP(), nullable=True),
sa.Column('last_viewed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('last_viewed_by', sa.String(100), nullable=True),
sa.Column('view_count', sa.Integer(), server_default='0'),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
# 移除外鍵約束,因為初始化時 tenants 表可能還不存在
)
op.create_index('idx_installation_sessions_tenant', 'installation_sessions', ['tenant_id'])
op.create_index('idx_installation_sessions_status', 'installation_sessions', ['status'])
# 2. installation_checklist_items (檢查項目定義)
op.create_table(
'installation_checklist_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category', sa.String(50), nullable=False),
sa.Column('item_code', sa.String(100), unique=True, nullable=False),
sa.Column('item_name', sa.String(200), nullable=False),
sa.Column('check_type', sa.String(50), nullable=False),
sa.Column('check_command', sa.Text(), nullable=True),
sa.Column('expected_value', sa.Text(), nullable=True),
sa.Column('min_requirement', sa.Text(), nullable=True),
sa.Column('recommended_value', sa.Text(), nullable=True),
sa.Column('is_required', sa.Boolean(), server_default='true'),
sa.Column('sequence_order', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_checklist_items_category', 'installation_checklist_items', ['category'])
op.create_index('idx_checklist_items_order', 'installation_checklist_items', ['sequence_order'])
# 3. installation_checklist_results (檢查結果)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_checklist_results',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('checklist_item_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(20), nullable=False),
sa.Column('actual_value', sa.Text(), nullable=True),
sa.Column('checked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('checked_by', sa.String(100), nullable=True),
sa.Column('auto_checked', sa.Boolean(), server_default='false'),
sa.Column('remarks', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['checklist_item_id'], ['installation_checklist_items.id'], ondelete='CASCADE')
)
op.create_index('idx_checklist_results_session', 'installation_checklist_results', ['session_id'])
op.create_index('idx_checklist_results_status', 'installation_checklist_results', ['status'])
# 4. installation_steps (安裝步驟定義)
op.create_table(
'installation_steps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('step_code', sa.String(50), unique=True, nullable=False),
sa.Column('step_name', sa.String(200), nullable=False),
sa.Column('phase', sa.String(20), nullable=False),
sa.Column('sequence_order', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('execution_type', sa.String(50), nullable=True),
sa.Column('execution_script', sa.Text(), nullable=True),
sa.Column('depends_on_steps', postgresql.ARRAY(sa.String()), nullable=True),
sa.Column('is_required', sa.Boolean(), server_default='true'),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_installation_steps_phase', 'installation_steps', ['phase'])
op.create_index('idx_installation_steps_order', 'installation_steps', ['sequence_order'])
# 5. installation_logs (安裝執行記錄)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('step_id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(20), nullable=False),
sa.Column('started_at', sa.TIMESTAMP(), nullable=True),
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('executed_by', sa.String(100), nullable=True),
sa.Column('execution_method', sa.String(50), nullable=True),
sa.Column('result_data', postgresql.JSONB(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('retry_count', sa.Integer(), server_default='0'),
sa.Column('remarks', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['step_id'], ['installation_steps.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
)
op.create_index('idx_installation_logs_session', 'installation_logs', ['session_id'])
op.create_index('idx_installation_logs_status', 'installation_logs', ['status'])
# 6. installation_tenant_info (租戶初始化資訊)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_tenant_info',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), unique=True, nullable=True), # 允許 NULL
sa.Column('session_id', sa.Integer(), nullable=True),
# 公司基本資訊
sa.Column('company_name', sa.String(200), nullable=True),
sa.Column('company_name_en', sa.String(200), nullable=True),
sa.Column('tax_id', sa.String(50), nullable=True),
sa.Column('industry', sa.String(100), nullable=True),
sa.Column('company_size', sa.String(20), nullable=True),
# 聯絡資訊
sa.Column('phone', sa.String(50), nullable=True),
sa.Column('fax', sa.String(50), nullable=True),
sa.Column('email', sa.String(200), nullable=True),
sa.Column('website', sa.String(200), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('address_en', sa.Text(), nullable=True),
# 負責人資訊
sa.Column('representative_name', sa.String(100), nullable=True),
sa.Column('representative_title', sa.String(100), nullable=True),
sa.Column('representative_email', sa.String(200), nullable=True),
sa.Column('representative_phone', sa.String(50), nullable=True),
# 系統管理員資訊
sa.Column('admin_employee_id', sa.String(50), nullable=True),
sa.Column('admin_username', sa.String(100), nullable=True),
sa.Column('admin_legal_name', sa.String(100), nullable=True),
sa.Column('admin_english_name', sa.String(100), nullable=True),
sa.Column('admin_email', sa.String(200), nullable=True),
sa.Column('admin_phone', sa.String(50), nullable=True),
# 初始設定
sa.Column('default_language', sa.String(10), server_default='zh-TW'),
sa.Column('timezone', sa.String(50), server_default='Asia/Taipei'),
sa.Column('date_format', sa.String(20), server_default='YYYY-MM-DD'),
sa.Column('currency', sa.String(10), server_default='TWD'),
# 狀態追蹤
sa.Column('is_completed', sa.Boolean(), server_default='false'),
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('completed_by', sa.String(100), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
)
# 7. installation_department_setup (部門架構設定)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_department_setup',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('department_code', sa.String(50), nullable=False),
sa.Column('department_name', sa.String(200), nullable=False),
sa.Column('department_name_en', sa.String(200), nullable=True),
sa.Column('email_domain', sa.String(100), nullable=True),
sa.Column('parent_code', sa.String(50), nullable=True),
sa.Column('depth', sa.Integer(), server_default='0'),
sa.Column('manager_name', sa.String(100), nullable=True),
sa.Column('is_created', sa.Boolean(), server_default='false'),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
)
op.create_index('idx_dept_setup_session', 'installation_department_setup', ['session_id'])
# 8. temporary_passwords (臨時密碼)
# 注意tenant_id 和 employee_id 不設外鍵,因為初始化時這些表可能還不存在
op.create_table(
'temporary_passwords',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('employee_id', sa.Integer(), nullable=True), # 允許 NULL不設外鍵
sa.Column('username', sa.String(100), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=True),
# 密碼資訊
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('plain_password', sa.String(100), nullable=True),
sa.Column('password_method', sa.String(20), nullable=True),
sa.Column('is_temporary', sa.Boolean(), server_default='true'),
sa.Column('must_change_on_login', sa.Boolean(), server_default='true'),
# 有效期限
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('expires_at', sa.TIMESTAMP(), nullable=True),
# 使用狀態
sa.Column('is_used', sa.Boolean(), server_default='false'),
sa.Column('used_at', sa.TIMESTAMP(), nullable=True),
sa.Column('first_login_at', sa.TIMESTAMP(), nullable=True),
sa.Column('password_changed_at', sa.TIMESTAMP(), nullable=True),
# 查看控制
sa.Column('is_viewable', sa.Boolean(), server_default='true'),
sa.Column('viewable_until', sa.TIMESTAMP(), nullable=True),
sa.Column('view_count', sa.Integer(), server_default='0'),
sa.Column('last_viewed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('first_viewed_at', sa.TIMESTAMP(), nullable=True),
# 明文密碼清除記錄
sa.Column('plain_password_cleared_at', sa.TIMESTAMP(), nullable=True),
sa.Column('cleared_reason', sa.String(100), nullable=True),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 和 employee_id 的外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
)
op.create_index('idx_temp_pwd_username', 'temporary_passwords', ['username'])
op.create_index('idx_temp_pwd_session', 'temporary_passwords', ['session_id'])
# 9. installation_access_logs (存取審計日誌)
op.create_table(
'installation_access_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=False),
sa.Column('action', sa.String(50), nullable=False),
sa.Column('action_by', sa.String(100), nullable=True),
sa.Column('action_method', sa.String(50), nullable=True),
sa.Column('ip_address', sa.String(50), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('access_granted', sa.Boolean(), nullable=True),
sa.Column('deny_reason', sa.String(200), nullable=True),
sa.Column('sensitive_data_accessed', postgresql.ARRAY(sa.String()), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
)
op.create_index('idx_access_logs_session', 'installation_access_logs', ['session_id'])
op.create_index('idx_access_logs_action', 'installation_access_logs', ['action'])
op.create_index('idx_access_logs_created', 'installation_access_logs', ['created_at'])
def downgrade():
"""移除初始化系統相關資料表"""
op.drop_index('idx_access_logs_created', 'installation_access_logs')
op.drop_index('idx_access_logs_action', 'installation_access_logs')
op.drop_index('idx_access_logs_session', 'installation_access_logs')
op.drop_table('installation_access_logs')
op.drop_index('idx_temp_pwd_session', 'temporary_passwords')
op.drop_index('idx_temp_pwd_username', 'temporary_passwords')
op.drop_table('temporary_passwords')
op.drop_index('idx_dept_setup_session', 'installation_department_setup')
op.drop_table('installation_department_setup')
op.drop_table('installation_tenant_info')
op.drop_index('idx_installation_logs_status', 'installation_logs')
op.drop_index('idx_installation_logs_session', 'installation_logs')
op.drop_table('installation_logs')
op.drop_index('idx_installation_steps_order', 'installation_steps')
op.drop_index('idx_installation_steps_phase', 'installation_steps')
op.drop_table('installation_steps')
op.drop_index('idx_checklist_results_status', 'installation_checklist_results')
op.drop_index('idx_checklist_results_session', 'installation_checklist_results')
op.drop_table('installation_checklist_results')
op.drop_index('idx_checklist_items_order', 'installation_checklist_items')
op.drop_index('idx_checklist_items_category', 'installation_checklist_items')
op.drop_table('installation_checklist_items')
op.drop_index('idx_installation_sessions_status', 'installation_sessions')
op.drop_index('idx_installation_sessions_tenant', 'installation_sessions')
op.drop_table('installation_sessions')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
"""remove_deprecated_tenant_employees_table
Revision ID: 5e95bf5ff0af
Revises: 844ac73765a3
Create Date: 2026-02-23 01:07:49.244754
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5e95bf5ff0af'
down_revision: Union[str, None] = '844ac73765a3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 警告:這個 migration 會刪除所有相關的測試資料
# 確保在開發環境執行,不要在正式環境執行
# Step 1: 刪除所有參照 tenant_employees 的外鍵約束
op.drop_constraint('department_members_employee_id_fkey', 'tenant_dept_members', type_='foreignkey')
op.drop_constraint('email_accounts_employee_id_fkey', 'tenant_email_accounts', type_='foreignkey')
op.drop_constraint('network_drives_employee_id_fkey', 'tenant_network_drives', type_='foreignkey')
op.drop_constraint('permissions_employee_id_fkey', 'tenant_permissions', type_='foreignkey')
op.drop_constraint('permissions_granted_by_fkey', 'tenant_permissions', type_='foreignkey')
# Step 2: 清空相關表的測試資料(因為外鍵已被移除)
op.execute('TRUNCATE TABLE tenant_dept_members CASCADE')
op.execute('TRUNCATE TABLE tenant_email_accounts CASCADE')
op.execute('TRUNCATE TABLE tenant_network_drives CASCADE')
op.execute('TRUNCATE TABLE tenant_permissions CASCADE')
# Step 3: 刪除 tenant_employees 表
op.drop_table('tenant_employees')
def downgrade() -> None:
# 不支援 downgrade無法復原已刪除的資料
raise NotImplementedError("Cannot downgrade from removing tenant_employees table")

View File

@@ -0,0 +1,42 @@
"""add tenant code prefix and domain fields to installation_tenant_info
Revision ID: 844ac73765a3
Revises: ba655ef20407
Create Date: 2026-02-23 00:33:56.554891
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '844ac73765a3'
down_revision: Union[str, None] = 'ba655ef20407'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 新增租戶代碼和員工編號前綴欄位
op.add_column('installation_tenant_info', sa.Column('tenant_code', sa.String(50), nullable=True))
op.add_column('installation_tenant_info', sa.Column('tenant_prefix', sa.String(10), nullable=True))
# 新增郵件網域相關欄位
op.add_column('installation_tenant_info', sa.Column('domain_set', sa.Integer, nullable=True, server_default='2'))
op.add_column('installation_tenant_info', sa.Column('domain', sa.String(100), nullable=True))
# 新增公司聯絡資訊欄位(對應 tenants 表)
op.add_column('installation_tenant_info', sa.Column('tel', sa.String(20), nullable=True))
op.add_column('installation_tenant_info', sa.Column('add', sa.Text, nullable=True))
def downgrade() -> None:
# 移除欄位
op.drop_column('installation_tenant_info', 'add')
op.drop_column('installation_tenant_info', 'tel')
op.drop_column('installation_tenant_info', 'domain')
op.drop_column('installation_tenant_info', 'domain_set')
op.drop_column('installation_tenant_info', 'tenant_prefix')
op.drop_column('installation_tenant_info', 'tenant_code')

View File

@@ -0,0 +1,78 @@
"""create_system_functions_table
Revision ID: a1b2c3d4e5f6
Revises: 5e95bf5ff0af
Create Date: 2026-02-23 10:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '5e95bf5ff0af'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 建立 system_functions 資料表
op.create_table(
'system_functions',
sa.Column('id', sa.Integer(), nullable=False, comment='資料編號'),
sa.Column('code', sa.String(length=200), nullable=False, comment='系統功能代碼/功能英文名稱'),
sa.Column('upper_function_id', sa.Integer(), nullable=False, server_default='0', comment='上層功能代碼 (0為初始層)'),
sa.Column('name', sa.String(length=200), nullable=False, comment='系統功能中文名稱'),
sa.Column('function_type', sa.Integer(), nullable=False, comment='系統功能類型 (1:node, 2:function)'),
sa.Column('order', sa.Integer(), nullable=False, comment='系統功能次序'),
sa.Column('function_icon', sa.String(length=200), nullable=False, server_default='', comment='功能圖示'),
sa.Column('module_code', sa.String(length=200), nullable=True, comment='功能模組名稱 (function_type=2 必填)'),
sa.Column('module_functions', postgresql.JSON(), nullable=False, server_default='[]', comment='模組項目 (View,Create,Read,Update,Delete,Print,File)'),
sa.Column('description', sa.Text(), nullable=False, server_default='', comment='說明 (富文本格式)'),
sa.Column('is_mana', sa.Boolean(), nullable=False, server_default='true', comment='系統管理'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='啟用'),
sa.Column('edit_by', sa.Integer(), nullable=False, comment='資料建立者'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), comment='資料最新建立時間'),
sa.Column('updated_at', sa.DateTime(), nullable=True, onupdate=sa.func.now(), comment='資料最新修改時間'),
sa.PrimaryKeyConstraint('id')
)
# 建立索引
op.create_index('ix_system_functions_code', 'system_functions', ['code'])
op.create_index('ix_system_functions_upper_function_id', 'system_functions', ['upper_function_id'])
op.create_index('ix_system_functions_function_type', 'system_functions', ['function_type'])
op.create_index('ix_system_functions_is_active', 'system_functions', ['is_active'])
# 建立自動編號序列 (從 10 開始)
op.execute("""
CREATE SEQUENCE system_functions_id_seq
START WITH 10
INCREMENT BY 1
MINVALUE 10
NO MAXVALUE
CACHE 1;
""")
# 設定 id 欄位使用序列
op.execute("""
ALTER TABLE system_functions
ALTER COLUMN id SET DEFAULT nextval('system_functions_id_seq');
""")
# 將序列所有權給予資料表
op.execute("""
ALTER SEQUENCE system_functions_id_seq
OWNED BY system_functions.id;
""")
def downgrade() -> None:
op.drop_index('ix_system_functions_is_active', table_name='system_functions')
op.drop_index('ix_system_functions_function_type', table_name='system_functions')
op.drop_index('ix_system_functions_upper_function_id', table_name='system_functions')
op.drop_index('ix_system_functions_code', table_name='system_functions')
op.execute('DROP SEQUENCE IF EXISTS system_functions_id_seq CASCADE')
op.drop_table('system_functions')

View File

@@ -0,0 +1,78 @@
"""add system status table
Revision ID: ba655ef20407
Revises: ddbf7bb95812
Create Date: 2026-02-22 20:47:37.691492
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ba655ef20407'
down_revision: Union[str, None] = 'ddbf7bb95812'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""建立系統狀態記錄表"""
# installation_system_status (系統狀態記錄)
# 用途:記錄系統當前所處的階段 (Initialization/Operational/Transition)
op.create_table(
'installation_system_status',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('current_phase', sa.String(20), nullable=False), # initialization/operational/transition
sa.Column('previous_phase', sa.String(20), nullable=True),
sa.Column('phase_changed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('phase_changed_by', sa.String(100), nullable=True),
sa.Column('phase_change_reason', sa.Text(), nullable=True),
# Initialization 階段資訊
sa.Column('initialized_at', sa.TIMESTAMP(), nullable=True),
sa.Column('initialized_by', sa.String(100), nullable=True),
sa.Column('initialization_completed', sa.Boolean(), server_default='false'),
# Operational 階段資訊
sa.Column('last_health_check_at', sa.TIMESTAMP(), nullable=True),
sa.Column('health_check_status', sa.String(20), nullable=True), # healthy/degraded/unhealthy
sa.Column('operational_since', sa.TIMESTAMP(), nullable=True),
# Transition 階段資訊
sa.Column('transition_started_at', sa.TIMESTAMP(), nullable=True),
sa.Column('transition_approved_by', sa.String(100), nullable=True),
sa.Column('env_db_consistent', sa.Boolean(), nullable=True),
sa.Column('consistency_checked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('inconsistencies', sa.Text(), nullable=True), # JSON 格式記錄不一致項目
# 系統鎖定(防止誤操作)
sa.Column('is_locked', sa.Boolean(), server_default='false'),
sa.Column('locked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('locked_by', sa.String(100), nullable=True),
sa.Column('lock_reason', sa.String(200), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_system_status_phase', 'installation_system_status', ['current_phase'])
# 插入預設記錄(初始階段)
op.execute("""
INSERT INTO installation_system_status
(current_phase, initialization_completed, is_locked)
VALUES ('initialization', false, false)
""")
def downgrade() -> None:
"""移除系統狀態記錄表"""
op.drop_index('idx_system_status_phase', 'installation_system_status')
op.drop_table('installation_system_status')

View File

@@ -0,0 +1,56 @@
"""add environment config table
Revision ID: ddbf7bb95812
Revises: 0014
Create Date: 2026-02-22 20:32:58.070446
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ddbf7bb95812'
down_revision: Union[str, None] = '0014'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""建立環境配置資料表"""
# installation_environment_config (環境配置記錄)
# 用途:記錄所有環境配置資訊,供初始化檢查使用
op.create_table(
'installation_environment_config',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('config_key', sa.String(100), nullable=False, unique=True),
sa.Column('config_value', sa.Text(), nullable=True),
sa.Column('config_category', sa.String(50), nullable=False), # redis/database/keycloak/mailserver/nextcloud/traefik
sa.Column('is_sensitive', sa.Boolean(), server_default='false'),
sa.Column('is_configured', sa.Boolean(), server_default='false'),
sa.Column('configured_at', sa.TIMESTAMP(), nullable=True),
sa.Column('configured_by', sa.String(100), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
)
op.create_index('idx_env_config_key', 'installation_environment_config', ['config_key'])
op.create_index('idx_env_config_category', 'installation_environment_config', ['config_category'])
op.create_index('idx_env_config_session', 'installation_environment_config', ['session_id'])
def downgrade() -> None:
"""移除環境配置資料表"""
op.drop_index('idx_env_config_session', 'installation_environment_config')
op.drop_index('idx_env_config_category', 'installation_environment_config')
op.drop_index('idx_env_config_key', 'installation_environment_config')
op.drop_table('installation_environment_config')

View File

@@ -0,0 +1,30 @@
"""Initial schema
Revision ID: fba4e3f40f05
Revises:
Create Date: 2026-02-12 11:42:34.613474
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'fba4e3f40f05'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

0
backend/app/__init__.py Normal file
View File

View File

222
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,222 @@
"""
API 依賴項
用於依賴注入
"""
from typing import Generator, Optional, Dict, Any
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.base import PaginationParams
from app.services.keycloak_service import keycloak_service
from app.models import Tenant
# OAuth2 Bearer Token Scheme
security = HTTPBearer(auto_error=False)
def get_pagination_params(
page: int = 1,
page_size: int = 20,
) -> PaginationParams:
"""
獲取分頁參數
Args:
page: 頁碼 (從 1 開始)
page_size: 每頁數量
Returns:
PaginationParams: 分頁參數
Raises:
HTTPException: 參數驗證失敗
"""
if page < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Page must be greater than 0"
)
if page_size < 1 or page_size > 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Page size must be between 1 and 100"
)
return PaginationParams(page=page, page_size=page_size)
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[Dict[str, Any]]:
"""
獲取當前登入用戶 (從 JWT Token)
Args:
credentials: HTTP Bearer Token
Returns:
dict: 用戶資訊 (包含 username, email, sub 等)
None: 未提供 Token 或 Token 無效時
Raises:
HTTPException: Token 無效時拋出 401
"""
if not credentials:
return None
token = credentials.credentials
# 驗證 Token
user_info = keycloak_service.get_user_info_from_token(token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
return user_info
def require_auth(
current_user: Optional[Dict[str, Any]] = Depends(get_current_user),
) -> Dict[str, Any]:
"""
要求必須認證
Args:
current_user: 當前用戶資訊
Returns:
dict: 用戶資訊
Raises:
HTTPException: 未認證時拋出 401
"""
if not current_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
def check_permission(required_roles: list = None):
"""
檢查用戶權限 (基於角色)
Args:
required_roles: 需要的角色列表 (例如: ["admin", "hr_manager"])
Returns:
function: 權限檢查函數
使用範例:
@router.get("/admin-only", dependencies=[Depends(check_permission(["admin"]))])
"""
if required_roles is None:
required_roles = []
def permission_checker(
current_user: Dict[str, Any] = Depends(require_auth)
) -> Dict[str, Any]:
"""檢查用戶是否有所需權限"""
# TODO: 從 Keycloak Token 解析用戶角色
# 目前暫時允許所有已認證用戶
user_roles = current_user.get("realm_access", {}).get("roles", [])
if required_roles:
has_permission = any(role in user_roles for role in required_roles)
if not has_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Required roles: {', '.join(required_roles)}"
)
return current_user
return permission_checker
def get_current_tenant(
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
) -> Tenant:
"""
獲取當前租戶 (從 JWT Token 的 realm)
多租戶架構:每個租戶對應一個 Keycloak Realm
- JWT Token 來自哪個 Realm就屬於哪個租戶
- 透過 iss (Issuer) 欄位解析 Realm 名稱
- 查詢 tenants 表找到對應租戶
Args:
current_user: 當前用戶資訊 (含 iss)
db: 資料庫 Session
Returns:
Tenant: 租戶物件
Raises:
HTTPException: 租戶不存在或未啟用時拋出 403
範例:
iss: "https://auth.lab.taipei/realms/porscheworld"
→ realm_name: "porscheworld"
→ tenant.keycloak_realm: "porscheworld"
"""
# 從 JWT iss 欄位解析 Realm 名稱
# iss 格式: "https://{domain}/realms/{realm_name}"
iss = current_user.get("iss", "")
if not iss:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token: missing issuer"
)
# 解析 realm_name
try:
realm_name = iss.split("/realms/")[-1]
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token: cannot parse realm"
)
# 查詢租戶
tenant = db.query(Tenant).filter(
Tenant.keycloak_realm == realm_name,
Tenant.is_active == True
).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Tenant not found or inactive: {realm_name}"
)
return tenant
def get_tenant_id(
tenant: Tenant = Depends(get_current_tenant)
) -> int:
"""
獲取當前租戶 ID (簡化版)
用於只需要 tenant_id 的場景
Args:
tenant: 租戶物件
Returns:
int: 租戶 ID
"""
return tenant.id

View File

@@ -0,0 +1,3 @@
"""
API v1 模組
"""

View File

@@ -0,0 +1,209 @@
"""
審計日誌 API
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.audit_log import AuditLog
from app.schemas.audit_log import (
AuditLogResponse,
AuditLogListItem,
AuditLogFilter,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.api.deps import get_pagination_params
router = APIRouter()
@router.get("/", response_model=PaginatedResponse)
def get_audit_logs(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
action: Optional[str] = Query(None, description="操作類型篩選"),
resource_type: Optional[str] = Query(None, description="資源類型篩選"),
resource_id: Optional[int] = Query(None, description="資源 ID 篩選"),
performed_by: Optional[str] = Query(None, description="操作者篩選"),
start_date: Optional[datetime] = Query(None, description="開始日期"),
end_date: Optional[datetime] = Query(None, description="結束日期"),
):
"""
獲取審計日誌列表
支援:
- 分頁
- 多種篩選條件
- 時間範圍篩選
"""
query = db.query(AuditLog)
# 操作類型篩選
if action:
query = query.filter(AuditLog.action == action)
# 資源類型篩選
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
# 資源 ID 篩選
if resource_id is not None:
query = query.filter(AuditLog.resource_id == resource_id)
# 操作者篩選
if performed_by:
query = query.filter(AuditLog.performed_by.ilike(f"%{performed_by}%"))
# 時間範圍篩選
if start_date:
query = query.filter(AuditLog.performed_at >= start_date)
if end_date:
query = query.filter(AuditLog.performed_at <= end_date)
# 總數
total = query.count()
# 分頁 (按時間倒序)
offset = (pagination.page - 1) * pagination.page_size
audit_logs = query.order_by(
AuditLog.performed_at.desc()
).offset(offset).limit(pagination.page_size).all()
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=[AuditLogListItem.model_validate(log) for log in audit_logs],
)
@router.get("/{audit_log_id}", response_model=AuditLogResponse)
def get_audit_log(
audit_log_id: int,
db: Session = Depends(get_db),
):
"""
獲取審計日誌詳情
"""
audit_log = db.query(AuditLog).filter(
AuditLog.id == audit_log_id
).first()
if not audit_log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Audit log with id {audit_log_id} not found"
)
return AuditLogResponse.model_validate(audit_log)
@router.get("/resource/{resource_type}/{resource_id}", response_model=List[AuditLogListItem])
def get_resource_audit_logs(
resource_type: str,
resource_id: int,
db: Session = Depends(get_db),
):
"""
獲取特定資源的所有審計日誌
Args:
resource_type: 資源類型 (employee, identity, department, etc.)
resource_id: 資源 ID
Returns:
該資源的所有操作記錄 (按時間倒序)
"""
audit_logs = db.query(AuditLog).filter(
AuditLog.resource_type == resource_type,
AuditLog.resource_id == resource_id
).order_by(AuditLog.performed_at.desc()).all()
return [AuditLogListItem.model_validate(log) for log in audit_logs]
@router.get("/user/{username}", response_model=List[AuditLogListItem])
def get_user_audit_logs(
username: str,
db: Session = Depends(get_db),
limit: int = Query(100, le=1000, description="限制返回數量"),
):
"""
獲取特定用戶的操作記錄
Args:
username: 操作者 SSO 帳號
limit: 限制返回數量 (預設 100,最大 1000)
Returns:
該用戶的操作記錄 (按時間倒序)
"""
audit_logs = db.query(AuditLog).filter(
AuditLog.performed_by == username
).order_by(AuditLog.performed_at.desc()).limit(limit).all()
return [AuditLogListItem.model_validate(log) for log in audit_logs]
@router.get("/stats/summary")
def get_audit_stats(
db: Session = Depends(get_db),
start_date: Optional[datetime] = Query(None, description="開始日期"),
end_date: Optional[datetime] = Query(None, description="結束日期"),
):
"""
獲取審計日誌統計
返回:
- 按操作類型分組的統計
- 按資源類型分組的統計
- 操作頻率最高的用戶
"""
query = db.query(AuditLog)
if start_date:
query = query.filter(AuditLog.performed_at >= start_date)
if end_date:
query = query.filter(AuditLog.performed_at <= end_date)
# 總操作數
total_operations = query.count()
# 按操作類型統計
from sqlalchemy import func
action_stats = db.query(
AuditLog.action,
func.count(AuditLog.id).label('count')
).group_by(AuditLog.action).all()
# 按資源類型統計
resource_stats = db.query(
AuditLog.resource_type,
func.count(AuditLog.id).label('count')
).group_by(AuditLog.resource_type).all()
# 操作最多的用戶 (Top 10)
top_users = db.query(
AuditLog.performed_by,
func.count(AuditLog.id).label('count')
).group_by(AuditLog.performed_by).order_by(
func.count(AuditLog.id).desc()
).limit(10).all()
return {
"total_operations": total_operations,
"by_action": {action: count for action, count in action_stats},
"by_resource_type": {resource: count for resource, count in resource_stats},
"top_users": [
{"username": user, "operations": count}
for user, count in top_users
]
}

362
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,362 @@
"""
認證 API
處理登入、登出、Token 管理
"""
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo
from app.schemas.response import MessageResponse
from app.api.deps import get_current_user, require_auth
from app.services.keycloak_service import keycloak_service
from app.services.audit_service import audit_service
router = APIRouter()
@router.post("/login", response_model=TokenResponse)
def login(
login_data: LoginRequest,
request: Request,
db: Session = Depends(get_db),
):
"""
用戶登入
使用 Keycloak Direct Access Grant (Resource Owner Password Credentials)
獲取 Access Token 和 Refresh Token
Args:
login_data: 登入憑證 (username, password)
request: HTTP Request (用於獲取 IP)
db: 資料庫 Session
Returns:
TokenResponse: Access Token 和 Refresh Token
Raises:
HTTPException: 登入失敗時拋出 401
"""
try:
# 使用 Keycloak 進行認證
token_response = keycloak_service.openid.token(
login_data.username,
login_data.password
)
# 記錄登入成功的審計日誌
audit_service.log_login(
db=db,
username=login_data.username,
ip_address=audit_service.get_client_ip(request),
success=True
)
return TokenResponse(
access_token=token_response["access_token"],
token_type=token_response["token_type"],
expires_in=token_response["expires_in"],
refresh_token=token_response.get("refresh_token")
)
except Exception as e:
# 記錄登入失敗的審計日誌
audit_service.log_login(
db=db,
username=login_data.username,
ip_address=audit_service.get_client_ip(request),
success=False
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
)
@router.post("/logout", response_model=MessageResponse)
def logout(
request: Request,
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
):
"""
用戶登出
記錄登出審計日誌
Args:
request: HTTP Request
current_user: 當前用戶資訊
db: 資料庫 Session
Returns:
MessageResponse: 登出成功訊息
"""
# 記錄登出審計日誌
audit_service.log_logout(
db=db,
username=current_user["username"],
ip_address=audit_service.get_client_ip(request)
)
# TODO: 可選擇在 Keycloak 端撤銷 Token
# keycloak_service.openid.logout(refresh_token)
return MessageResponse(
message=f"User {current_user['username']} logged out successfully"
)
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(
refresh_token: str,
):
"""
刷新 Access Token
使用 Refresh Token 獲取新的 Access Token
Args:
refresh_token: Refresh Token
Returns:
TokenResponse: 新的 Access Token 和 Refresh Token
Raises:
HTTPException: Refresh Token 無效時拋出 401
"""
try:
# 使用 Refresh Token 獲取新的 Access Token
token_response = keycloak_service.openid.refresh_token(refresh_token)
return TokenResponse(
access_token=token_response["access_token"],
token_type=token_response["token_type"],
expires_in=token_response["expires_in"],
refresh_token=token_response.get("refresh_token")
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
@router.get("/me", response_model=UserInfo)
def get_current_user_info(
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db)
):
"""
獲取當前用戶資訊
從 JWT Token 解析用戶資訊,並查詢租戶資訊
Args:
current_user: 當前用戶資訊 (從 Token 解析)
db: 資料庫 Session
Returns:
UserInfo: 用戶詳細資訊(包含租戶資訊)
"""
# 查詢用戶所屬租戶
from app.models.tenant import Tenant
from app.models.employee import Employee
tenant_info = None
# 從 email 查詢員工,取得租戶資訊
email = current_user.get("email")
if email:
employee = db.query(Employee).filter(Employee.email == email).first()
if employee and employee.tenant_id:
tenant = db.query(Tenant).filter(Tenant.id == employee.tenant_id).first()
if tenant:
tenant_info = {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"is_sysmana": tenant.is_sysmana
}
return UserInfo(
sub=current_user.get("sub", ""),
username=current_user.get("username", ""),
email=current_user.get("email", ""),
first_name=current_user.get("first_name"),
last_name=current_user.get("last_name"),
email_verified=current_user.get("email_verified", False),
tenant=tenant_info
)
@router.post("/change-password", response_model=MessageResponse)
def change_password(
old_password: str,
new_password: str,
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
request: Request = None,
):
"""
修改密碼
用戶修改自己的密碼
Args:
old_password: 舊密碼
new_password: 新密碼
current_user: 當前用戶資訊
db: 資料庫 Session
request: HTTP Request
Returns:
MessageResponse: 成功訊息
Raises:
HTTPException: 舊密碼錯誤或修改失敗時拋出錯誤
"""
username = current_user["username"]
try:
# 1. 驗證舊密碼
try:
keycloak_service.openid.token(username, old_password)
except:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Old password is incorrect"
)
# 2. 獲取用戶 Keycloak ID
user = keycloak_service.get_user_by_username(username)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in Keycloak"
)
user_id = user["id"]
# 3. 重設密碼 (非臨時密碼)
success = keycloak_service.reset_password(
user_id=user_id,
new_password=new_password,
temporary=False
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to change password"
)
# 4. 記錄審計日誌
audit_service.log(
db=db,
action="change_password",
resource_type="authentication",
performed_by=username,
details={"success": True},
ip_address=audit_service.get_client_ip(request) if request else None
)
return MessageResponse(
message="Password changed successfully"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to change password: {str(e)}"
)
@router.post("/reset-password/{username}", response_model=MessageResponse)
def reset_user_password(
username: str,
new_password: str,
temporary: bool = True,
current_user: Dict[str, Any] = Depends(require_auth),
db: Session = Depends(get_db),
request: Request = None,
):
"""
重設用戶密碼 (管理員功能)
管理員為其他用戶重設密碼
Args:
username: 目標用戶名稱
new_password: 新密碼
temporary: 是否為臨時密碼 (用戶首次登入需修改)
current_user: 當前用戶資訊 (管理員)
db: 資料庫 Session
request: HTTP Request
Returns:
MessageResponse: 成功訊息
Raises:
HTTPException: 權限不足或重設失敗時拋出錯誤
"""
# TODO: 檢查是否為管理員
# 目前暫時允許所有已認證用戶
try:
# 1. 獲取目標用戶
user = keycloak_service.get_user_by_username(username)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {username} not found"
)
user_id = user["id"]
# 2. 重設密碼
success = keycloak_service.reset_password(
user_id=user_id,
new_password=new_password,
temporary=temporary
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
# 3. 記錄審計日誌
audit_service.log(
db=db,
action="reset_password",
resource_type="authentication",
performed_by=current_user["username"],
details={
"target_user": username,
"temporary": temporary,
"success": True
},
ip_address=audit_service.get_client_ip(request) if request else None
)
return MessageResponse(
message=f"Password reset for user {username}"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset password: {str(e)}"
)

View File

@@ -0,0 +1,213 @@
"""
事業部管理 API
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.business_unit import BusinessUnit
from app.schemas.business_unit import (
BusinessUnitCreate,
BusinessUnitUpdate,
BusinessUnitResponse,
BusinessUnitListItem,
)
from app.schemas.department import DepartmentListItem
from app.schemas.response import MessageResponse
router = APIRouter()
@router.get("/", response_model=List[BusinessUnitListItem])
def get_business_units(
db: Session = Depends(get_db),
include_inactive: bool = False,
):
"""
獲取事業部列表
Args:
include_inactive: 是否包含停用的事業部
"""
query = db.query(BusinessUnit)
if not include_inactive:
query = query.filter(BusinessUnit.is_active == True)
business_units = query.order_by(BusinessUnit.id).all()
return [BusinessUnitListItem.model_validate(bu) for bu in business_units]
@router.get("/{business_unit_id}", response_model=BusinessUnitResponse)
def get_business_unit(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
獲取事業部詳情
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = len(business_unit.departments)
response.employees_count = business_unit.employee_identities.count()
return response
@router.post("/", response_model=BusinessUnitResponse, status_code=status.HTTP_201_CREATED)
def create_business_unit(
business_unit_data: BusinessUnitCreate,
db: Session = Depends(get_db),
):
"""
創建事業部
檢查:
- code 唯一性
- email_domain 唯一性
"""
# 檢查 code 是否已存在
existing_code = db.query(BusinessUnit).filter(
BusinessUnit.code == business_unit_data.code
).first()
if existing_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Business unit code '{business_unit_data.code}' already exists"
)
# 檢查 email_domain 是否已存在
existing_domain = db.query(BusinessUnit).filter(
BusinessUnit.email_domain == business_unit_data.email_domain
).first()
if existing_domain:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email domain '{business_unit_data.email_domain}' already exists"
)
# 創建事業部
business_unit = BusinessUnit(**business_unit_data.model_dump())
db.add(business_unit)
db.commit()
db.refresh(business_unit)
# TODO: 創建審計日誌
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = 0
response.employees_count = 0
return response
@router.put("/{business_unit_id}", response_model=BusinessUnitResponse)
def update_business_unit(
business_unit_id: int,
business_unit_data: BusinessUnitUpdate,
db: Session = Depends(get_db),
):
"""
更新事業部
注意: code 和 email_domain 不可修改 (在 Schema 中已限制)
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
# 更新欄位
update_data = business_unit_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(business_unit, field, value)
db.commit()
db.refresh(business_unit)
# TODO: 創建審計日誌
response = BusinessUnitResponse.model_validate(business_unit)
response.departments_count = len(business_unit.departments)
response.employees_count = business_unit.employee_identities.count()
return response
@router.delete("/{business_unit_id}", response_model=MessageResponse)
def delete_business_unit(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
停用事業部
注意: 這是軟刪除,只將 is_active 設為 False
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
# 檢查是否有活躍的員工
active_employees = business_unit.employee_identities.filter_by(is_active=True).count()
if active_employees > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot deactivate business unit with {active_employees} active employees"
)
business_unit.is_active = False
db.commit()
# TODO: 創建審計日誌
return MessageResponse(
message=f"Business unit '{business_unit.name}' has been deactivated"
)
@router.get("/{business_unit_id}/departments", response_model=List[DepartmentListItem])
def get_business_unit_departments(
business_unit_id: int,
db: Session = Depends(get_db),
):
"""
獲取事業部的所有部門
"""
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {business_unit_id} not found"
)
return [DepartmentListItem.model_validate(dept) for dept in business_unit.departments]

View File

@@ -0,0 +1,226 @@
"""
部門成員管理 API
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.department_member import DepartmentMember
from app.models.employee import Employee
from app.models.department import Department
from app.schemas.response import MessageResponse
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
return 1
@router.get("/")
def get_department_members(
db: Session = Depends(get_db),
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
department_id: Optional[int] = Query(None, description="部門 ID 篩選"),
include_inactive: bool = False,
):
"""
取得部門成員列表
可依員工 ID 或部門 ID 篩選
"""
tenant_id = get_current_tenant_id()
query = db.query(DepartmentMember).filter(DepartmentMember.tenant_id == tenant_id)
if employee_id:
query = query.filter(DepartmentMember.employee_id == employee_id)
if department_id:
query = query.filter(DepartmentMember.department_id == department_id)
if not include_inactive:
query = query.filter(DepartmentMember.is_active == True)
members = query.all()
result = []
for m in members:
dept = m.department
emp = m.employee
result.append({
"id": m.id,
"employee_id": m.employee_id,
"employee_name": emp.legal_name if emp else None,
"employee_number": emp.employee_id if emp else None,
"department_id": m.department_id,
"department_name": dept.name if dept else None,
"department_code": dept.code if dept else None,
"department_depth": dept.depth if dept else None,
"position": m.position,
"membership_type": m.membership_type,
"is_active": m.is_active,
"joined_at": m.joined_at,
"ended_at": m.ended_at,
})
return result
@router.post("/", status_code=status.HTTP_201_CREATED)
def add_employee_to_department(
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
將員工加入部門
Body:
{
"employee_id": 1,
"department_id": 3,
"position": "資深工程師",
"membership_type": "permanent"
}
"""
tenant_id = get_current_tenant_id()
employee_id = data.get("employee_id")
department_id = data.get("department_id")
position = data.get("position")
membership_type = data.get("membership_type", "permanent")
if not employee_id or not department_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="employee_id and department_id are required"
)
# 驗證員工存在
employee = db.query(Employee).filter(
Employee.id == employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 驗證部門存在
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
# 檢查是否已存在
existing = db.query(DepartmentMember).filter(
DepartmentMember.employee_id == employee_id,
DepartmentMember.department_id == department_id,
).first()
if existing:
if existing.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Employee {employee_id} is already a member of department {department_id}"
)
else:
# 重新啟用
existing.is_active = True
existing.position = position
existing.membership_type = membership_type
existing.ended_at = None
db.commit()
db.refresh(existing)
return {
"id": existing.id,
"employee_id": existing.employee_id,
"department_id": existing.department_id,
"position": existing.position,
"membership_type": existing.membership_type,
"is_active": existing.is_active,
}
member = DepartmentMember(
tenant_id=tenant_id,
employee_id=employee_id,
department_id=department_id,
position=position,
membership_type=membership_type,
)
db.add(member)
db.commit()
db.refresh(member)
audit_service.log_action(
request=request,
db=db,
action="add_department_member",
resource_type="department_member",
resource_id=member.id,
details={
"employee_id": employee_id,
"department_id": department_id,
"position": position,
},
)
return {
"id": member.id,
"employee_id": member.employee_id,
"department_id": member.department_id,
"position": member.position,
"membership_type": member.membership_type,
"is_active": member.is_active,
"joined_at": member.joined_at,
}
@router.delete("/{member_id}", response_model=MessageResponse)
def remove_employee_from_department(
member_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""將員工從部門移除 (軟刪除)"""
tenant_id = get_current_tenant_id()
member = db.query(DepartmentMember).filter(
DepartmentMember.id == member_id,
DepartmentMember.tenant_id == tenant_id,
).first()
if not member:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department member with id {member_id} not found"
)
from datetime import datetime
member.is_active = False
member.ended_at = datetime.utcnow()
db.commit()
audit_service.log_action(
request=request,
db=db,
action="remove_department_member",
resource_type="department_member",
resource_id=member_id,
details={
"employee_id": member.employee_id,
"department_id": member.department_id,
},
)
return MessageResponse(message=f"Employee removed from department successfully")

View File

@@ -0,0 +1,373 @@
"""
部門管理 API (統一樹狀結構)
設計原則:
- depth=0, parent_id=NULL: 第一層部門 (原事業部),可設定 email_domain
- depth>=1: 子部門email_domain 繼承第一層祖先
- 取代舊的 business_units API
"""
from typing import List, Optional, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.api.deps import get_tenant_id, get_current_tenant
from app.models.department import Department
from app.models.department_member import DepartmentMember
from app.schemas.department import (
DepartmentCreate,
DepartmentUpdate,
DepartmentResponse,
DepartmentListItem,
DepartmentTreeNode,
)
from app.schemas.response import MessageResponse
router = APIRouter()
def get_effective_email_domain(department: Department, db: Session) -> str | None:
"""取得部門的有效郵件網域 (第一層自身,子層向上追溯)"""
if department.depth == 0:
return department.email_domain
if department.parent_id:
parent = db.query(Department).filter(Department.id == department.parent_id).first()
if parent:
return get_effective_email_domain(parent, db)
return None
def build_tree(departments: List[Department], parent_id: int | None, db: Session) -> List[Dict]:
"""遞迴建立部門樹狀結構"""
nodes = []
for dept in departments:
if dept.parent_id == parent_id:
children = build_tree(departments, dept.id, db)
node = {
"id": dept.id,
"code": dept.code,
"name": dept.name,
"name_en": dept.name_en,
"depth": dept.depth,
"parent_id": dept.parent_id,
"email_domain": dept.email_domain,
"effective_email_domain": get_effective_email_domain(dept, db),
"email_address": dept.email_address,
"email_quota_mb": dept.email_quota_mb,
"description": dept.description,
"is_active": dept.is_active,
"is_top_level": dept.depth == 0 and dept.parent_id is None,
"member_count": dept.members.filter_by(is_active=True).count(),
"children": children,
}
nodes.append(node)
return nodes
@router.get("/tree")
def get_departments_tree(
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
include_inactive: bool = False,
):
"""
取得完整部門樹狀結構
回傳格式:
[
{
"id": 1, "code": "BD", "name": "業務發展部", "depth": 0,
"email_domain": "ease.taipei",
"children": [
{"id": 4, "code": "WIND", "name": "玄鐵風能部", "depth": 1, ...}
]
},
...
]
"""
query = db.query(Department).filter(Department.tenant_id == tenant_id)
if not include_inactive:
query = query.filter(Department.is_active == True)
all_departments = query.order_by(Department.depth, Department.id).all()
tree = build_tree(all_departments, None, db)
return tree
@router.get("/", response_model=List[DepartmentListItem])
def get_departments(
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
parent_id: Optional[int] = Query(None, description="上層部門 ID 篩選 (0=取得第一層)"),
depth: Optional[int] = Query(None, description="層次深度篩選 (0=第一層1=第二層)"),
include_inactive: bool = False,
):
"""
獲取部門列表
Args:
parent_id: 上層部門 ID 篩選
depth: 層次深度篩選 (0=第一層即原事業部1=第二層子部門)
include_inactive: 是否包含停用的部門
"""
query = db.query(Department).filter(Department.tenant_id == tenant_id)
if depth is not None:
query = query.filter(Department.depth == depth)
if parent_id is not None:
if parent_id == 0:
query = query.filter(Department.parent_id == None)
else:
query = query.filter(Department.parent_id == parent_id)
if not include_inactive:
query = query.filter(Department.is_active == True)
departments = query.order_by(Department.depth, Department.id).all()
result = []
for dept in departments:
item = DepartmentListItem.model_validate(dept)
item.effective_email_domain = get_effective_email_domain(dept, db)
item.member_count = dept.members.filter_by(is_active=True).count()
result.append(item)
return result
@router.get("/{department_id}", response_model=DepartmentResponse)
def get_department(
department_id: int,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""取得部門詳情"""
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
response = DepartmentResponse.model_validate(department)
response.effective_email_domain = get_effective_email_domain(department, db)
response.member_count = department.members.filter_by(is_active=True).count()
if department.parent:
response.parent_name = department.parent.name
return response
@router.post("/", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED)
def create_department(
department_data: DepartmentCreate,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
創建部門
規則:
- parent_id=NULL: 建立第一層部門 (depth=0),可設定 email_domain
- parent_id=有值: 建立子部門 (depth=parent.depth+1),不可設定 email_domain (繼承)
"""
depth = 0
parent = None
if department_data.parent_id:
# 檢查上層部門是否存在
parent = db.query(Department).filter(
Department.id == department_data.parent_id,
Department.tenant_id == tenant_id,
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Parent department with id {department_data.parent_id} not found"
)
depth = parent.depth + 1
# 子部門不可設定 email_domain
if hasattr(department_data, 'email_domain') and department_data.email_domain:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="email_domain can only be set on top-level departments (parent_id=NULL)"
)
# 檢查同層內 code 是否已存在
existing = db.query(Department).filter(
Department.tenant_id == tenant_id,
Department.parent_id == department_data.parent_id,
Department.code == department_data.code,
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Department code '{department_data.code}' already exists at this level"
)
data = department_data.model_dump()
data['tenant_id'] = tenant_id
data['depth'] = depth
department = Department(**data)
db.add(department)
db.commit()
db.refresh(department)
response = DepartmentResponse.model_validate(department)
response.effective_email_domain = get_effective_email_domain(department, db)
response.member_count = 0
if parent:
response.parent_name = parent.name
return response
@router.put("/{department_id}", response_model=DepartmentResponse)
def update_department(
department_id: int,
department_data: DepartmentUpdate,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
更新部門
注意: code 和 parent_id 建立後不可修改
第一層部門可更新 email_domain子部門不可更新 email_domain
"""
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
update_data = department_data.model_dump(exclude_unset=True)
# 子部門不可更新 email_domain
if 'email_domain' in update_data and department.depth > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="email_domain can only be set on top-level departments (depth=0)"
)
for field, value in update_data.items():
setattr(department, field, value)
db.commit()
db.refresh(department)
response = DepartmentResponse.model_validate(department)
response.effective_email_domain = get_effective_email_domain(department, db)
response.member_count = department.members.filter_by(is_active=True).count()
if department.parent:
response.parent_name = department.parent.name
return response
@router.delete("/{department_id}", response_model=MessageResponse)
def delete_department(
department_id: int,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
"""
停用部門 (軟刪除)
注意: 有活躍成員的部門不可停用
"""
department = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
# 檢查是否有活躍的成員
active_members = department.members.filter_by(is_active=True).count()
if active_members > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot deactivate department with {active_members} active members"
)
# 檢查是否有活躍的子部門
active_children = db.query(Department).filter(
Department.parent_id == department_id,
Department.is_active == True,
).count()
if active_children > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot deactivate department with {active_children} active sub-departments"
)
department.is_active = False
db.commit()
return MessageResponse(
message=f"Department '{department.name}' has been deactivated"
)
@router.get("/{department_id}/children")
def get_department_children(
department_id: int,
db: Session = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
include_inactive: bool = False,
):
"""取得部門的直接子部門列表"""
# 確認父部門存在
parent = db.query(Department).filter(
Department.id == department_id,
Department.tenant_id == tenant_id,
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {department_id} not found"
)
query = db.query(Department).filter(
Department.parent_id == department_id,
Department.tenant_id == tenant_id,
)
if not include_inactive:
query = query.filter(Department.is_active == True)
children = query.order_by(Department.id).all()
effective_domain = get_effective_email_domain(parent, db)
result = []
for dept in children:
item = DepartmentListItem.model_validate(dept)
item.effective_email_domain = effective_domain
item.member_count = dept.members.filter_by(is_active=True).count()
result.append(item)
return result

View File

@@ -0,0 +1,445 @@
"""
郵件帳號管理 API
符合 WebMail 設計規範 - 員工只能使用 HR Portal 授權的郵件帳號
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_
from app.db.session import get_db
from app.models.employee import Employee
from app.models.email_account import EmailAccount
from app.schemas.email_account import (
EmailAccountCreate,
EmailAccountUpdate,
EmailAccountResponse,
EmailAccountListItem,
EmailAccountQuotaUpdate,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.schemas.response import SuccessResponse, MessageResponse
from app.api.deps import get_pagination_params
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
return 1
@router.get("/", response_model=PaginatedResponse)
def get_email_accounts(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
is_active: Optional[bool] = Query(None, description="狀態篩選"),
search: Optional[str] = Query(None, description="搜尋郵件地址"),
):
"""
獲取郵件帳號列表
支援:
- 分頁
- 員工篩選
- 狀態篩選
- 郵件地址搜尋
"""
tenant_id = get_current_tenant_id()
query = db.query(EmailAccount).filter(EmailAccount.tenant_id == tenant_id)
# 員工篩選
if employee_id:
query = query.filter(EmailAccount.employee_id == employee_id)
# 狀態篩選
if is_active is not None:
query = query.filter(EmailAccount.is_active == is_active)
# 搜尋
if search:
search_pattern = f"%{search}%"
query = query.filter(EmailAccount.email_address.ilike(search_pattern))
# 總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
email_accounts = (
query.options(joinedload(EmailAccount.employee))
.offset(offset)
.limit(pagination.page_size)
.all()
)
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
# 組裝回應資料
items = []
for account in email_accounts:
item = EmailAccountListItem.model_validate(account)
item.employee_name = account.employee.legal_name if account.employee else None
item.employee_number = account.employee.employee_id if account.employee else None
items.append(item)
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=items,
)
@router.get("/{email_account_id}", response_model=EmailAccountResponse)
def get_email_account(
email_account_id: int,
db: Session = Depends(get_db),
):
"""
獲取郵件帳號詳情
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.post("/", response_model=EmailAccountResponse, status_code=status.HTTP_201_CREATED)
def create_email_account(
account_data: EmailAccountCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
創建郵件帳號
注意:
- 郵件地址必須唯一
- 員工必須存在
- 配額範圍: 1GB - 100GB
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == account_data.employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {account_data.employee_id} not found",
)
# 檢查郵件地址是否已存在
existing = db.query(EmailAccount).filter(
EmailAccount.email_address == account_data.email_address
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{account_data.email_address}' already exists",
)
# 創建郵件帳號
account = EmailAccount(
tenant_id=tenant_id,
employee_id=account_data.employee_id,
email_address=account_data.email_address,
quota_mb=account_data.quota_mb,
forward_to=account_data.forward_to,
auto_reply=account_data.auto_reply,
is_active=account_data.is_active,
)
db.add(account)
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="create_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"employee_id": account.employee_id,
"quota_mb": account.quota_mb,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
return response
@router.put("/{email_account_id}", response_model=EmailAccountResponse)
def update_email_account(
email_account_id: int,
account_data: EmailAccountUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新郵件帳號
可更新:
- 配額
- 轉寄地址
- 自動回覆
- 啟用狀態
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 記錄變更前的值
changes = {}
# 更新欄位
update_data = account_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
old_value = getattr(account, field)
if old_value != value:
changes[field] = {"from": old_value, "to": value}
setattr(account, field, value)
if changes:
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"changes": changes,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.patch("/{email_account_id}/quota", response_model=EmailAccountResponse)
def update_email_quota(
email_account_id: int,
quota_data: EmailAccountQuotaUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新郵件配額
快速更新配額的端點
"""
tenant_id = get_current_tenant_id()
account = (
db.query(EmailAccount)
.options(joinedload(EmailAccount.employee))
.filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
)
.first()
)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
old_quota = account.quota_mb
account.quota_mb = quota_data.quota_mb
db.commit()
db.refresh(account)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_email_quota",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"old_quota_mb": old_quota,
"new_quota_mb": quota_data.quota_mb,
},
)
# 組裝回應資料
response = EmailAccountResponse.model_validate(account)
response.employee_name = account.employee.legal_name if account.employee else None
response.employee_number = account.employee.employee_id if account.employee else None
return response
@router.delete("/{email_account_id}", response_model=MessageResponse)
def delete_email_account(
email_account_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
刪除郵件帳號
注意:
- 軟刪除: 設為停用
- 需要記錄審計日誌
"""
tenant_id = get_current_tenant_id()
account = db.query(EmailAccount).filter(
EmailAccount.id == email_account_id,
EmailAccount.tenant_id == tenant_id,
).first()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Email account with id {email_account_id} not found",
)
# 軟刪除: 設為停用
account.is_active = False
db.commit()
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="delete_email_account",
resource_type="email_account",
resource_id=account.id,
details={
"email_address": account.email_address,
"employee_id": account.employee_id,
},
)
return MessageResponse(
message=f"Email account {account.email_address} has been deactivated"
)
@router.get("/employees/{employee_id}/email-accounts")
def get_employee_email_accounts(
employee_id: int,
db: Session = Depends(get_db),
):
"""
取得員工授權的郵件帳號列表
符合 WebMail 設計規範 (HR Portal設計文件 §2):
- 員工不可自行新增郵件帳號
- 只能使用 HR Portal 授予的帳號
- 支援多帳號切換 (ISO 帳號管理流程)
回傳格式:
{
"user_id": "porsche.chen",
"email_accounts": [
{
"email": "porsche.chen@porscheworld.tw",
"quota_mb": 5120,
"status": "active",
...
}
]
}
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found",
)
# 查詢員工的所有啟用郵件帳號
accounts = (
db.query(EmailAccount)
.filter(
EmailAccount.employee_id == employee_id,
EmailAccount.tenant_id == tenant_id,
EmailAccount.is_active == True,
)
.all()
)
# 組裝符合 WebMail 設計規範的回應格式
email_accounts = []
for account in accounts:
email_accounts.append({
"email": account.email_address,
"quota_mb": account.quota_mb,
"status": "active" if account.is_active else "inactive",
"forward_to": account.forward_to,
"auto_reply": account.auto_reply,
})
return {
"user_id": employee.username_base,
"email_accounts": email_accounts,
}

View File

@@ -0,0 +1,468 @@
"""
員工到職流程 API (v3.1 多租戶架構)
使用關聯表方式管理部門、角色、服務
"""
from typing import List, Optional
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.db.session import get_db
from app.models.emp_resume import EmpResume
from app.models.emp_setting import EmpSetting
from app.models.department_member import DepartmentMember
from app.models.role import UserRoleAssignment
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
from app.models.personal_service import PersonalService
from app.schemas.response import MessageResponse
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
return 1
def get_current_user_id() -> str:
"""取得當前使用者 ID (暫時寫死, 未來從 JWT token sub 取得)"""
return "system-admin"
# ==================== Schemas ====================
class DepartmentAssignment(BaseModel):
"""部門分配"""
department_id: int
position: Optional[str] = None
membership_type: str = "permanent" # permanent/temporary/project
class OnboardingRequest(BaseModel):
"""到職請求"""
# 人員基本資料
resume_id: int # 已存在的 tenant_emp_resumes.id
# SSO 帳號資訊
keycloak_user_id: str # Keycloak UUID
keycloak_username: str # 登入帳號
# 任用資訊
hire_date: date
# 部門分配
departments: List[DepartmentAssignment]
# 角色分配
role_ids: List[int]
# 配額設定
storage_quota_gb: int = 20
email_quota_mb: int = 5120
# ==================== API Endpoints ====================
@router.post("/onboard", status_code=status.HTTP_201_CREATED)
def onboard_employee(
data: OnboardingRequest,
request: Request,
db: Session = Depends(get_db),
):
"""
完整員工到職流程
執行項目:
1. 建立員工任用設定 (tenant_emp_settings)
2. 分配部門歸屬 (tenant_dept_members)
3. 分配使用者角色 (tenant_user_role_assignments)
4. 啟用所有個人化服務 (tenant_emp_personal_service_settings)
範例:
{
"resume_id": 1,
"keycloak_user_id": "550e8400-e29b-41d4-a716-446655440000",
"keycloak_username": "wang.ming",
"hire_date": "2026-02-20",
"departments": [
{"department_id": 9, "position": "資深工程師", "membership_type": "permanent"},
{"department_id": 12, "position": "專案經理", "membership_type": "project"}
],
"role_ids": [1, 2],
"storage_quota_gb": 20,
"email_quota_mb": 5120
}
"""
tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
# Step 1: 檢查 resume 是否存在
resume = db.query(EmpResume).filter(
EmpResume.id == data.resume_id,
EmpResume.tenant_id == tenant_id
).first()
if not resume:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Resume ID {data.resume_id} not found"
)
# 檢查是否已有任用設定
existing_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.tenant_resume_id == data.resume_id,
EmpSetting.is_active == True
).first()
if existing_setting:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Employee already onboarded (emp_code: {existing_setting.tenant_emp_code})"
)
# Step 2: 建立員工任用設定 (seq_no 由觸發器自動生成)
emp_setting = EmpSetting(
tenant_id=tenant_id,
# seq_no 會由觸發器自動生成
tenant_resume_id=data.resume_id,
# tenant_emp_code 會由觸發器自動生成
tenant_keycloak_user_id=data.keycloak_user_id,
tenant_keycloak_username=data.keycloak_username,
hire_at=data.hire_date,
storage_quota_gb=data.storage_quota_gb,
email_quota_mb=data.email_quota_mb,
employment_status="active",
is_active=True,
edit_by=current_user
)
db.add(emp_setting)
db.flush() # 取得自動生成的 seq_no 和 tenant_emp_code
# Step 3: 分配部門歸屬
dept_count = 0
for dept_assignment in data.departments:
dept_member = DepartmentMember(
tenant_id=tenant_id,
employee_id=data.resume_id, # 使用 resume_id 作為 employee_id
department_id=dept_assignment.department_id,
position=dept_assignment.position,
membership_type=dept_assignment.membership_type,
joined_at=datetime.utcnow(),
assigned_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(dept_member)
dept_count += 1
# Step 4: 分配使用者角色
role_count = 0
for role_id in data.role_ids:
role_assignment = UserRoleAssignment(
tenant_id=tenant_id,
keycloak_user_id=data.keycloak_user_id,
role_id=role_id,
assigned_at=datetime.utcnow(),
assigned_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(role_assignment)
role_count += 1
# Step 5: 啟用所有個人化服務
all_services = db.query(PersonalService).filter(
PersonalService.is_active == True
).all()
service_count = 0
for service in all_services:
# 根據服務類型設定配額
quota_gb = data.storage_quota_gb if service.service_code == "Drive" else None
quota_mb = data.email_quota_mb if service.service_code == "Email" else None
service_setting = EmpPersonalServiceSetting(
tenant_id=tenant_id,
tenant_keycloak_user_id=data.keycloak_user_id,
service_id=service.id,
quota_gb=quota_gb,
quota_mb=quota_mb,
enabled_at=datetime.utcnow(),
enabled_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(service_setting)
service_count += 1
db.commit()
db.refresh(emp_setting)
# 審計日誌
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="employee_onboard",
resource_type="tenant_emp_settings",
resource_id=f"{tenant_id}-{emp_setting.seq_no}",
details=f"Onboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw}): "
f"{dept_count} departments, {role_count} roles, {service_count} services",
ip_address=request.client.host if request.client else None
)
return {
"message": "Employee onboarded successfully",
"employee": {
"tenant_id": emp_setting.tenant_id,
"seq_no": emp_setting.seq_no,
"tenant_emp_code": emp_setting.tenant_emp_code,
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
"keycloak_username": emp_setting.tenant_keycloak_username,
"name": resume.name_tw,
"hire_date": emp_setting.hire_at.isoformat(),
},
"summary": {
"departments_assigned": dept_count,
"roles_assigned": role_count,
"services_enabled": service_count,
}
}
@router.post("/{tenant_id}/{seq_no}/offboard")
def offboard_employee(
tenant_id: int,
seq_no: int,
request: Request,
db: Session = Depends(get_db),
):
"""
員工離職流程
執行項目:
1. 軟刪除所有部門歸屬
2. 撤銷所有使用者角色
3. 停用所有個人化服務
4. 設定員工狀態為 resigned
"""
current_tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
# 檢查租戶權限
if tenant_id != current_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No permission to access this tenant"
)
# 查詢員工任用設定
emp_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.seq_no == seq_no
).first()
if not emp_setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
)
if emp_setting.employment_status == "resigned":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Employee already resigned"
)
keycloak_user_id = emp_setting.tenant_keycloak_user_id
resume_id = emp_setting.tenant_resume_id
# Step 1: 軟刪除所有部門歸屬
dept_members = db.query(DepartmentMember).filter(
DepartmentMember.tenant_id == tenant_id,
DepartmentMember.employee_id == resume_id,
DepartmentMember.is_active == True
).all()
for dm in dept_members:
dm.is_active = False
dm.ended_at = datetime.utcnow()
dm.removed_by = current_user
dm.edit_by = current_user
# Step 2: 撤銷所有使用者角色
role_assignments = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.is_active == True
).all()
for ra in role_assignments:
ra.is_active = False
ra.revoked_at = datetime.utcnow()
ra.revoked_by = current_user
ra.edit_by = current_user
# Step 3: 停用所有個人化服務
service_settings = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.is_active == True
).all()
for ss in service_settings:
ss.is_active = False
ss.disabled_at = datetime.utcnow()
ss.disabled_by = current_user
ss.edit_by = current_user
# Step 4: 設定離職日期和狀態
emp_setting.resign_date = date.today()
emp_setting.employment_status = "resigned"
emp_setting.is_active = False
emp_setting.edit_by = current_user
db.commit()
# 審計日誌
resume = emp_setting.resume
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="employee_offboard",
resource_type="tenant_emp_settings",
resource_id=f"{tenant_id}-{seq_no}",
details=f"Offboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw if resume else 'Unknown'}): "
f"{len(dept_members)} departments removed, {len(role_assignments)} roles revoked, "
f"{len(service_settings)} services disabled",
ip_address=request.client.host if request.client else None
)
return {
"message": "Employee offboarded successfully",
"employee": {
"tenant_emp_code": emp_setting.tenant_emp_code,
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
},
"summary": {
"departments_removed": len(dept_members),
"roles_revoked": len(role_assignments),
"services_disabled": len(service_settings),
}
}
@router.get("/{tenant_id}/{seq_no}/status")
def get_employee_onboarding_status(
tenant_id: int,
seq_no: int,
db: Session = Depends(get_db),
):
"""
查詢員工完整的到職狀態
回傳:
- 員工基本資訊
- 部門歸屬列表
- 角色分配列表
- 個人化服務列表
"""
current_tenant_id = get_current_tenant_id()
if tenant_id != current_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No permission to access this tenant"
)
emp_setting = db.query(EmpSetting).filter(
EmpSetting.tenant_id == tenant_id,
EmpSetting.seq_no == seq_no
).first()
if not emp_setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
)
resume = emp_setting.resume
keycloak_user_id = emp_setting.tenant_keycloak_user_id
# 查詢部門歸屬
dept_members = db.query(DepartmentMember).filter(
DepartmentMember.tenant_id == tenant_id,
DepartmentMember.employee_id == emp_setting.tenant_resume_id,
DepartmentMember.is_active == True
).all()
departments = [
{
"department_id": dm.department_id,
"department_name": dm.department.name if dm.department else None,
"position": dm.position,
"membership_type": dm.membership_type,
"joined_at": dm.joined_at.isoformat() if dm.joined_at else None,
}
for dm in dept_members
]
# 查詢角色分配
role_assignments = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.is_active == True
).all()
roles = [
{
"role_id": ra.role_id,
"role_name": ra.role.role_name if ra.role else None,
"role_code": ra.role.role_code if ra.role else None,
"assigned_at": ra.assigned_at.isoformat() if ra.assigned_at else None,
}
for ra in role_assignments
]
# 查詢個人化服務
service_settings = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.is_active == True
).all()
services = [
{
"service_id": ss.service_id,
"service_name": ss.service.service_name if ss.service else None,
"service_code": ss.service.service_code if ss.service else None,
"quota_gb": ss.quota_gb,
"quota_mb": ss.quota_mb,
"enabled_at": ss.enabled_at.isoformat() if ss.enabled_at else None,
}
for ss in service_settings
]
return {
"employee": {
"tenant_id": emp_setting.tenant_id,
"seq_no": emp_setting.seq_no,
"tenant_emp_code": emp_setting.tenant_emp_code,
"name": resume.name_tw if resume else None,
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
"keycloak_username": emp_setting.tenant_keycloak_username,
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
"employment_status": emp_setting.employment_status,
"storage_quota_gb": emp_setting.storage_quota_gb,
"email_quota_mb": emp_setting.email_quota_mb,
},
"departments": departments,
"roles": roles,
"services": services,
}

View File

@@ -0,0 +1,381 @@
"""
員工管理 API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session
from sqlalchemy import or_
from app.db.session import get_db
from app.models.employee import Employee, EmployeeStatus
from app.models.emp_setting import EmpSetting
from app.models.emp_resume import EmpResume
from app.models.department import Department
from app.models.department_member import DepartmentMember
from app.schemas.employee import (
EmployeeCreate,
EmployeeUpdate,
EmployeeResponse,
EmployeeListItem,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.schemas.response import SuccessResponse, MessageResponse
from app.api.deps import get_pagination_params
from app.services.audit_service import audit_service
router = APIRouter()
@router.get("/", response_model=PaginatedResponse)
def get_employees(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
status_filter: Optional[EmployeeStatus] = Query(None, description="員工狀態篩選"),
search: Optional[str] = Query(None, description="搜尋關鍵字 (姓名或帳號)"),
):
"""
獲取員工列表
支援:
- 分頁
- 狀態篩選
- 關鍵字搜尋 (姓名、帳號)
"""
# ⚠️ 暫時改為查詢 EmpSetting (因為 Employee model 對應的 tenant_employees 表不存在)
query = db.query(EmpSetting).join(EmpResume, EmpSetting.tenant_resume_id == EmpResume.id)
# 狀態篩選
if status_filter:
query = query.filter(EmpSetting.employment_status == status_filter)
# 搜尋 (在 EmpResume 中搜尋)
if search:
search_pattern = f"%{search}%"
query = query.filter(
or_(
EmpResume.legal_name.ilike(search_pattern),
EmpResume.english_name.ilike(search_pattern),
EmpSetting.tenant_emp_code.ilike(search_pattern),
)
)
# 總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
emp_settings = query.offset(offset).limit(pagination.page_size).all()
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
# 轉換為回應格式 (暫時簡化,不使用 EmployeeListItem)
items = []
for emp_setting in emp_settings:
resume = emp_setting.resume
items.append({
"id": emp_setting.id if hasattr(emp_setting, 'id') else emp_setting.tenant_id * 10000 + emp_setting.seq_no,
"employee_id": emp_setting.tenant_emp_code,
"legal_name": resume.legal_name if resume else "",
"english_name": resume.english_name if resume else "",
"status": emp_setting.employment_status,
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
"is_active": emp_setting.is_active,
})
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=items,
)
@router.get("/{employee_id}", response_model=EmployeeResponse)
def get_employee(
employee_id: int,
db: Session = Depends(get_db),
):
"""
獲取員工詳情 (Phase 2.4: 包含主要身份完整資訊)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 組建回應
response = EmployeeResponse.model_validate(employee)
response.has_network_drive = employee.network_drive is not None
response.department_count = sum(1 for m in employee.department_memberships if m.is_active)
return response
@router.post("/", response_model=EmployeeResponse, status_code=status.HTTP_201_CREATED)
def create_employee(
employee_data: EmployeeCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
創建員工 (Phase 2.3: 同時創建第一個員工身份)
自動生成員工編號 (EMP001, EMP002, ...)
同時創建第一個 employee_identity 記錄 (主要身份)
"""
# 檢查 username_base 是否已存在
existing = db.query(Employee).filter(
Employee.username_base == employee_data.username_base
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Username '{employee_data.username_base}' already exists"
)
# 檢查部門是否存在 (如果有提供)
department = None
if employee_data.department_id:
department = db.query(Department).filter(
Department.id == employee_data.department_id,
Department.is_active == True
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {employee_data.department_id} not found or inactive"
)
# 生成員工編號
last_employee = db.query(Employee).order_by(Employee.id.desc()).first()
if last_employee and last_employee.employee_id.startswith("EMP"):
try:
last_number = int(last_employee.employee_id[3:])
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
employee_id = f"EMP{new_number:03d}"
# 創建員工
# TODO: 從 JWT token 或 session 獲取 tenant_id,目前暫時使用預設值 1
tenant_id = 1 # 預設租戶 ID
employee = Employee(
tenant_id=tenant_id,
employee_id=employee_id,
username_base=employee_data.username_base,
legal_name=employee_data.legal_name,
english_name=employee_data.english_name,
phone=employee_data.phone,
mobile=employee_data.mobile,
hire_date=employee_data.hire_date,
status=EmployeeStatus.ACTIVE,
)
db.add(employee)
db.flush() # 先 flush 以取得 employee.id
# 若有指定部門,建立 department_member 紀錄
if department:
membership = DepartmentMember(
tenant_id=tenant_id,
employee_id=employee.id,
department_id=department.id,
position=employee_data.job_title,
membership_type="permanent",
is_active=True,
)
db.add(membership)
db.commit()
db.refresh(employee)
# 創建審計日誌
audit_service.log_create(
db=db,
resource_type="employee",
resource_id=employee.id,
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
details={
"employee_id": employee.employee_id,
"username_base": employee.username_base,
"legal_name": employee.legal_name,
"department_id": employee_data.department_id,
"job_title": employee_data.job_title,
},
ip_address=audit_service.get_client_ip(request),
)
response = EmployeeResponse.model_validate(employee)
response.has_network_drive = False
response.department_count = 1 if department else 0
return response
@router.put("/{employee_id}", response_model=EmployeeResponse)
def update_employee(
employee_id: int,
employee_data: EmployeeUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新員工資料
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 記錄舊值 (用於審計日誌)
old_values = audit_service.model_to_dict(employee)
# 更新欄位 (只更新提供的欄位)
update_data = employee_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(employee, field, value)
db.commit()
db.refresh(employee)
# 創建審計日誌
if update_data: # 只有實際有更新時才記錄
audit_service.log_update(
db=db,
resource_type="employee",
resource_id=employee.id,
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
old_values={k: old_values[k] for k in update_data.keys() if k in old_values},
new_values=update_data,
ip_address=audit_service.get_client_ip(request),
)
response = EmployeeResponse.model_validate(employee)
response.has_network_drive = employee.network_drive is not None
response.department_count = sum(1 for m in employee.department_memberships if m.is_active)
return response
@router.delete("/{employee_id}", response_model=MessageResponse)
def delete_employee(
employee_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
停用員工
注意: 這是軟刪除,只將狀態設為 terminated
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 軟刪除
employee.status = EmployeeStatus.TERMINATED
# 停用所有部門成員資格
from datetime import datetime
memberships_deactivated = 0
for membership in employee.department_memberships:
if membership.is_active:
membership.is_active = False
membership.ended_at = datetime.utcnow()
memberships_deactivated += 1
# 停用 NAS 帳號
has_nas = employee.network_drive is not None
if employee.network_drive:
employee.network_drive.is_active = False
db.commit()
# 創建審計日誌
audit_service.log_delete(
db=db,
resource_type="employee",
resource_id=employee.id,
performed_by="system@porscheworld.tw", # TODO: 從 JWT Token 獲取
details={
"employee_id": employee.employee_id,
"username_base": employee.username_base,
"legal_name": employee.legal_name,
"memberships_deactivated": memberships_deactivated,
"nas_deactivated": has_nas,
},
ip_address=audit_service.get_client_ip(request),
)
# TODO: 停用 Keycloak 帳號
return MessageResponse(
message=f"Employee {employee.employee_id} ({employee.legal_name}) has been terminated"
)
@router.post("/{employee_id}/activate", response_model=MessageResponse)
def activate_employee(
employee_id: int,
db: Session = Depends(get_db),
):
"""
重新啟用員工
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
employee.status = EmployeeStatus.ACTIVE
db.commit()
# TODO: 創建審計日誌
return MessageResponse(
message=f"Employee {employee.employee_id} ({employee.legal_name}) has been activated"
)
@router.get("/{employee_id}/identities", response_model=List, deprecated=True)
def get_employee_identities(
employee_id: int,
db: Session = Depends(get_db),
):
"""
[已廢棄] 獲取員工的所有身份
此端點已廢棄,請使用 GET /department-members/?employee_id={id} 取代
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
# 廢棄端點: 回傳空列表,請改用 /department-members/?employee_id={id}
return []

View File

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

View File

@@ -0,0 +1,290 @@
"""
系統階段轉換 API
處理三階段狀態轉換Initialization → Operational ↔ Transition
"""
import os
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from datetime import datetime
from app.api import deps
router = APIRouter()
@router.post("/complete-initialization")
async def complete_initialization(db: Session = Depends(deps.get_db)):
"""
完成初始化,將系統狀態從 Initialization 轉換到 Operational
條件檢查:
1. 必須已完成 Redis, Database, Keycloak 設定
2. 必須已建立公司資訊
3. 必須已建立管理員帳號
執行操作:
1. 更新 installation_system_status
2. 將 current_phase 從 'initialization' 改為 'operational'
3. 設定 initialization_completed = True
4. 記錄 initialized_at, operational_since
"""
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig, InstallationTenantInfo
try:
# 1. 取得系統狀態
system_status = db.query(InstallationSystemStatus).first()
if not system_status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="系統狀態記錄不存在"
)
if system_status.current_phase != "initialization":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"系統當前階段為 {system_status.current_phase},無法執行此操作"
)
# 2. 檢查必要配置是否完成
required_categories = ["redis", "database", "keycloak"]
configured_categories = db.query(InstallationEnvironmentConfig.config_category).filter(
InstallationEnvironmentConfig.is_configured == True
).distinct().all()
configured_categories = [cat[0] for cat in configured_categories]
missing = [cat for cat in required_categories if cat not in configured_categories]
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"尚未完成環境配置: {', '.join(missing)}"
)
# 3. 檢查是否已建立租戶資訊
tenant_info = db.query(InstallationTenantInfo).first()
if not tenant_info or not tenant_info.is_completed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="尚未完成公司資訊設定"
)
# 4. 更新系統狀態
now = datetime.now()
system_status.previous_phase = system_status.current_phase
system_status.current_phase = "operational"
system_status.phase_changed_at = now
system_status.phase_change_reason = "初始化完成,進入營運階段"
system_status.initialization_completed = True
system_status.initialized_at = now
system_status.operational_since = now
system_status.updated_at = now
db.commit()
db.refresh(system_status)
return {
"success": True,
"message": "系統初始化完成,已進入營運階段",
"current_phase": system_status.current_phase,
"operational_since": system_status.operational_since.isoformat(),
"next_action": "redirect_to_login"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"完成初始化失敗: {str(e)}"
)
@router.post("/switch-phase")
async def switch_phase(
target_phase: str,
reason: str = None,
db: Session = Depends(deps.get_db)
):
"""
切換系統階段Operational ↔ Transition
Args:
target_phase: operational | transition
reason: 切換原因
Rules:
- operational → transition: 需進行系統遷移時
- transition → operational: 完成遷移並通過一致性檢查後
"""
from app.models.installation import InstallationSystemStatus
if target_phase not in ["operational", "transition"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="target_phase 必須為 'operational''transition'"
)
try:
system_status = db.query(InstallationSystemStatus).first()
if not system_status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="系統狀態記錄不存在"
)
# 不允許從 initialization 直接切換
if system_status.current_phase == "initialization":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="初始化階段無法直接切換,請先完成初始化"
)
# 檢查是否已是目標階段
if system_status.current_phase == target_phase:
return {
"success": True,
"message": f"系統已處於 {target_phase} 階段",
"current_phase": target_phase
}
# 特殊檢查:從 transition 回到 operational 必須通過一致性檢查
if system_status.current_phase == "transition" and target_phase == "operational":
if not system_status.env_db_consistent:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="環境與資料庫不一致,無法切換回營運階段"
)
# 執行切換
now = datetime.now()
system_status.previous_phase = system_status.current_phase
system_status.current_phase = target_phase
system_status.phase_changed_at = now
system_status.phase_change_reason = reason or f"手動切換至 {target_phase} 階段"
# 根據目標階段更新相關欄位
if target_phase == "transition":
system_status.transition_started_at = now
system_status.env_db_consistent = None # 重置一致性狀態
system_status.inconsistencies = None
elif target_phase == "operational":
system_status.operational_since = now
system_status.updated_at = now
db.commit()
db.refresh(system_status)
return {
"success": True,
"message": f"已切換至 {target_phase} 階段",
"current_phase": system_status.current_phase,
"previous_phase": system_status.previous_phase,
"phase_changed_at": system_status.phase_changed_at.isoformat()
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"階段切換失敗: {str(e)}"
)
@router.post("/check-consistency")
async def check_env_db_consistency(db: Session = Depends(deps.get_db)):
"""
檢查 .env 檔案與資料庫配置的一致性Transition 階段使用)
比對項目:
- REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB
- DATABASE_URL, DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER
- KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_ADMIN_USERNAME
Returns:
is_consistent: True/False
inconsistencies: 不一致項目列表
"""
from app.models.installation import InstallationSystemStatus, InstallationEnvironmentConfig
try:
system_status = db.query(InstallationSystemStatus).first()
if not system_status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="系統狀態記錄不存在"
)
# 從資料庫讀取配置
db_configs = {}
config_records = db.query(InstallationEnvironmentConfig).filter(
InstallationEnvironmentConfig.is_configured == True
).all()
for record in config_records:
db_configs[record.config_key] = record.config_value
# 從 .env 讀取配置
env_configs = {}
env_file_path = os.path.join(os.getcwd(), '.env')
if os.path.exists(env_file_path):
with open(env_file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
env_configs[key.strip()] = value.strip()
# 比對差異(排除敏感資訊的顯示)
inconsistencies = []
checked_keys = set(db_configs.keys()) | set(env_configs.keys())
for key in checked_keys:
db_value = db_configs.get(key, "[NOT SET]")
env_value = env_configs.get(key, "[NOT SET]")
if db_value != env_value:
# 檢查是否為敏感資訊
is_sensitive = any(sensitive in key.lower() for sensitive in ['password', 'secret', 'key'])
inconsistencies.append({
"config_key": key,
"db_value": "[HIDDEN]" if is_sensitive else db_value,
"env_value": "[HIDDEN]" if is_sensitive else env_value,
"is_sensitive": is_sensitive
})
is_consistent = len(inconsistencies) == 0
# 更新系統狀態
now = datetime.now()
system_status.env_db_consistent = is_consistent
system_status.consistency_checked_at = now
system_status.inconsistencies = json.dumps(inconsistencies, ensure_ascii=False) if inconsistencies else None
system_status.updated_at = now
db.commit()
return {
"is_consistent": is_consistent,
"checked_at": now.isoformat(),
"total_configs": len(checked_keys),
"inconsistency_count": len(inconsistencies),
"inconsistencies": inconsistencies,
"message": "環境配置一致" if is_consistent else f"發現 {len(inconsistencies)} 項不一致"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"一致性檢查失敗: {str(e)}"
)

View File

@@ -0,0 +1,364 @@
"""
員工身份管理 API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.employee import Employee
from app.models.employee_identity import EmployeeIdentity
from app.models.business_unit import BusinessUnit
from app.models.department import Department
from app.schemas.employee_identity import (
EmployeeIdentityCreate,
EmployeeIdentityUpdate,
EmployeeIdentityResponse,
EmployeeIdentityListItem,
)
from app.schemas.response import MessageResponse
router = APIRouter()
@router.get("/", response_model=List[EmployeeIdentityListItem])
def get_identities(
db: Session = Depends(get_db),
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
business_unit_id: Optional[int] = Query(None, description="事業部 ID 篩選"),
department_id: Optional[int] = Query(None, description="部門 ID 篩選"),
is_active: Optional[bool] = Query(None, description="是否活躍"),
):
"""
獲取員工身份列表
支援多種篩選條件
"""
query = db.query(EmployeeIdentity)
if employee_id:
query = query.filter(EmployeeIdentity.employee_id == employee_id)
if business_unit_id:
query = query.filter(EmployeeIdentity.business_unit_id == business_unit_id)
if department_id:
query = query.filter(EmployeeIdentity.department_id == department_id)
if is_active is not None:
query = query.filter(EmployeeIdentity.is_active == is_active)
identities = query.order_by(
EmployeeIdentity.employee_id,
EmployeeIdentity.is_primary.desc()
).all()
return [EmployeeIdentityListItem.model_validate(identity) for identity in identities]
@router.get("/{identity_id}", response_model=EmployeeIdentityResponse)
def get_identity(
identity_id: int,
db: Session = Depends(get_db),
):
"""
獲取員工身份詳情
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = identity.employee.legal_name
response.business_unit_name = identity.business_unit.name
response.email_domain = identity.business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response
@router.post("/", response_model=EmployeeIdentityResponse, status_code=status.HTTP_201_CREATED)
def create_identity(
identity_data: EmployeeIdentityCreate,
db: Session = Depends(get_db),
):
"""
創建員工身份
自動生成 SSO 帳號:
- 格式: {username_base}@{email_domain}
- 需要生成 Keycloak UUID (TODO)
檢查:
- 員工是否存在
- 事業部是否存在
- 部門是否存在 (如果指定)
- 同一員工在同一事業部只能有一個身份
"""
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == identity_data.employee_id
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {identity_data.employee_id} not found"
)
# 檢查事業部是否存在
business_unit = db.query(BusinessUnit).filter(
BusinessUnit.id == identity_data.business_unit_id
).first()
if not business_unit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Business unit with id {identity_data.business_unit_id} not found"
)
# 檢查部門是否存在 (如果指定)
if identity_data.department_id:
department = db.query(Department).filter(
Department.id == identity_data.department_id,
Department.business_unit_id == identity_data.business_unit_id
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Department with id {identity_data.department_id} not found in this business unit"
)
# 檢查同一員工在同一事業部是否已有身份
existing = db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity_data.employee_id,
EmployeeIdentity.business_unit_id == identity_data.business_unit_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Employee already has an identity in this business unit"
)
# 生成 SSO 帳號
username = f"{employee.username_base}@{business_unit.email_domain}"
# 檢查 SSO 帳號是否已存在
existing_username = db.query(EmployeeIdentity).filter(
EmployeeIdentity.username == username
).first()
if existing_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Username '{username}' already exists"
)
# TODO: 從 Keycloak 創建帳號並獲取 UUID
keycloak_id = f"temp-uuid-{employee.id}-{business_unit.id}"
# 創建身份
identity = EmployeeIdentity(
employee_id=identity_data.employee_id,
username=username,
keycloak_id=keycloak_id,
business_unit_id=identity_data.business_unit_id,
department_id=identity_data.department_id,
job_title=identity_data.job_title,
job_level=identity_data.job_level,
is_primary=identity_data.is_primary,
email_quota_mb=identity_data.email_quota_mb,
started_at=identity_data.started_at,
)
# 如果設為主要身份,取消其他主要身份
if identity_data.is_primary:
db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity_data.employee_id
).update({"is_primary": False})
db.add(identity)
db.commit()
db.refresh(identity)
# TODO: 創建審計日誌
# TODO: 創建 Keycloak 帳號
# TODO: 創建郵件帳號
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = employee.legal_name
response.business_unit_name = business_unit.name
response.email_domain = business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response
@router.put("/{identity_id}", response_model=EmployeeIdentityResponse)
def update_identity(
identity_id: int,
identity_data: EmployeeIdentityUpdate,
db: Session = Depends(get_db),
):
"""
更新員工身份
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
# 檢查部門是否屬於同一事業部 (如果更新部門)
if identity_data.department_id:
department = db.query(Department).filter(
Department.id == identity_data.department_id,
Department.business_unit_id == identity.business_unit_id
).first()
if not department:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Department does not belong to this business unit"
)
# 更新欄位
update_data = identity_data.model_dump(exclude_unset=True)
# 如果設為主要身份,取消其他主要身份
if update_data.get("is_primary"):
db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity.employee_id,
EmployeeIdentity.id != identity_id
).update({"is_primary": False})
for field, value in update_data.items():
setattr(identity, field, value)
db.commit()
db.refresh(identity)
# TODO: 創建審計日誌
# TODO: 更新 NAS 配額 (如果職級變更)
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = identity.employee.legal_name
response.business_unit_name = identity.business_unit.name
response.email_domain = identity.business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response
@router.delete("/{identity_id}", response_model=MessageResponse)
def delete_identity(
identity_id: int,
db: Session = Depends(get_db),
):
"""
刪除員工身份
注意:
- 如果是員工的最後一個身份,無法刪除
- 刪除後會停用對應的 Keycloak 和郵件帳號
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
# 檢查是否為員工的最後一個身份
total_identities = db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity.employee_id
).count()
if total_identities == 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete employee's last identity. Please terminate the employee instead."
)
# 軟刪除 (停用)
identity.is_active = False
identity.ended_at = db.func.current_date()
db.commit()
# TODO: 創建審計日誌
# TODO: 停用 Keycloak 帳號
# TODO: 停用郵件帳號
return MessageResponse(
message=f"Identity '{identity.username}' has been deactivated"
)
@router.post("/{identity_id}/set-primary", response_model=EmployeeIdentityResponse)
def set_primary_identity(
identity_id: int,
db: Session = Depends(get_db),
):
"""
設定為主要身份
將指定的身份設為員工的主要身份,並取消其他身份的主要狀態
"""
identity = db.query(EmployeeIdentity).filter(
EmployeeIdentity.id == identity_id
).first()
if not identity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Identity with id {identity_id} not found"
)
# 檢查身份是否已停用
if not identity.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot set inactive identity as primary"
)
# 取消同一員工的其他主要身份
db.query(EmployeeIdentity).filter(
EmployeeIdentity.employee_id == identity.employee_id,
EmployeeIdentity.id != identity_id
).update({"is_primary": False})
# 設為主要身份
identity.is_primary = True
db.commit()
db.refresh(identity)
# TODO: 創建審計日誌
response = EmployeeIdentityResponse.model_validate(identity)
response.employee_name = identity.employee.legal_name
response.business_unit_name = identity.business_unit.name
response.email_domain = identity.business_unit.email_domain
if identity.department:
response.department_name = identity.department.name
return response

View File

@@ -0,0 +1,176 @@
"""
員工生命週期管理 API
觸發員工到職、離職自動化流程
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.employee import Employee
from app.services.employee_lifecycle import get_employee_lifecycle_service
router = APIRouter()
@router.post("/employees/{employee_id}/onboard")
async def onboard_employee(
employee_id: int,
create_keycloak: bool = True,
create_email: bool = True,
create_drive: bool = True,
db: Session = Depends(get_db),
):
"""
觸發員工到職流程
自動執行:
- 建立 Keycloak SSO 帳號
- 建立主要郵件帳號
- 建立雲端硬碟帳號 (Drive Service非致命)
參數:
- create_keycloak: 是否建立 Keycloak 帳號 (預設: True)
- create_email: 是否建立郵件帳號 (預設: True)
- create_drive: 是否建立雲端硬碟帳號 (預設: TrueDrive Service 未上線時自動跳過)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"員工 ID {employee_id} 不存在"
)
if employee.status != "active":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"只能為在職員工執行到職流程 (目前狀態: {employee.status})"
)
lifecycle_service = get_employee_lifecycle_service()
results = await lifecycle_service.onboard_employee(
db=db,
employee=employee,
create_keycloak=create_keycloak,
create_email=create_email,
create_drive=create_drive,
)
return {
"message": "員工到職流程已觸發",
"employee": {
"id": employee.id,
"employee_id": employee.employee_id,
"legal_name": employee.legal_name,
},
"results": results,
}
@router.post("/employees/{employee_id}/offboard")
async def offboard_employee(
employee_id: int,
disable_keycloak: bool = True,
email_handling: str = "forward", # "forward" 或 "disable"
disable_drive: bool = True,
db: Session = Depends(get_db),
):
"""
觸發員工離職流程
自動執行:
- 停用 Keycloak SSO 帳號
- 處理郵件帳號 (轉發或停用)
- 停用雲端硬碟帳號 (Drive Service非致命)
參數:
- disable_keycloak: 是否停用 Keycloak 帳號 (預設: True)
- email_handling: 郵件處理方式 "forward""disable" (預設: forward)
- disable_drive: 是否停用雲端硬碟帳號 (預設: TrueDrive Service 未上線時自動跳過)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"員工 ID {employee_id} 不存在"
)
if email_handling not in ["forward", "disable"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="email_handling 必須是 'forward''disable'"
)
lifecycle_service = get_employee_lifecycle_service()
results = await lifecycle_service.offboard_employee(
db=db,
employee=employee,
disable_keycloak=disable_keycloak,
handle_email=email_handling,
disable_drive=disable_drive,
)
# 將員工狀態設為離職
employee.status = "terminated"
db.commit()
return {
"message": "員工離職流程已觸發",
"employee": {
"id": employee.id,
"employee_id": employee.employee_id,
"legal_name": employee.legal_name,
},
"results": results,
}
@router.get("/employees/{employee_id}/lifecycle-status")
async def get_lifecycle_status(
employee_id: int,
db: Session = Depends(get_db),
):
"""
查詢員工的生命週期狀態
回傳:
- Keycloak 帳號狀態
- 郵件帳號狀態
- 雲端硬碟帳號狀態 (Drive Service)
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"員工 ID {employee_id} 不存在"
)
# TODO: 實際查詢各系統的帳號狀態
return {
"employee": {
"id": employee.id,
"employee_id": employee.employee_id,
"legal_name": employee.legal_name,
"status": employee.status,
},
"systems": {
"keycloak": {
"has_account": False,
"is_enabled": False,
"message": "尚未整合 Keycloak API",
},
"email": {
"has_account": False,
"email_address": f"{employee.username_base}@porscheworld.tw",
"message": "尚未整合 MailPlus API",
},
"drive": {
"has_account": False,
"drive_url": "https://drive.ease.taipei",
"message": "Drive Service 尚未上線",
},
},
}

View File

@@ -0,0 +1,262 @@
"""
網路硬碟管理 API
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.employee import Employee
from app.models.network_drive import NetworkDrive
from app.schemas.network_drive import (
NetworkDriveCreate,
NetworkDriveUpdate,
NetworkDriveResponse,
NetworkDriveListItem,
NetworkDriveQuotaUpdate,
)
from app.schemas.response import MessageResponse
router = APIRouter()
@router.get("/", response_model=List[NetworkDriveListItem])
def get_network_drives(
db: Session = Depends(get_db),
is_active: bool = True,
):
"""
獲取網路硬碟列表
"""
query = db.query(NetworkDrive)
if is_active is not None:
query = query.filter(NetworkDrive.is_active == is_active)
network_drives = query.order_by(NetworkDrive.drive_name).all()
return [NetworkDriveListItem.model_validate(nd) for nd in network_drives]
@router.get("/{network_drive_id}", response_model=NetworkDriveResponse)
def get_network_drive(
network_drive_id: int,
db: Session = Depends(get_db),
):
"""
獲取網路硬碟詳情
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = network_drive.employee.legal_name
response.employee_username = network_drive.employee.username_base
return response
@router.post("/", response_model=NetworkDriveResponse, status_code=status.HTTP_201_CREATED)
def create_network_drive(
network_drive_data: NetworkDriveCreate,
db: Session = Depends(get_db),
):
"""
創建網路硬碟
檢查:
- 員工是否存在
- 員工是否已有 NAS 帳號
- drive_name 唯一性
"""
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == network_drive_data.employee_id
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {network_drive_data.employee_id} not found"
)
# 檢查員工是否已有 NAS 帳號
existing = db.query(NetworkDrive).filter(
NetworkDrive.employee_id == network_drive_data.employee_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Employee already has a network drive"
)
# 檢查 drive_name 是否已存在
existing_name = db.query(NetworkDrive).filter(
NetworkDrive.drive_name == network_drive_data.drive_name
).first()
if existing_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Drive name '{network_drive_data.drive_name}' already exists"
)
# 創建網路硬碟
network_drive = NetworkDrive(**network_drive_data.model_dump())
db.add(network_drive)
db.commit()
db.refresh(network_drive)
# TODO: 創建審計日誌
# TODO: 在 NAS 上創建實際帳號
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = employee.legal_name
response.employee_username = employee.username_base
return response
@router.put("/{network_drive_id}", response_model=NetworkDriveResponse)
def update_network_drive(
network_drive_id: int,
network_drive_data: NetworkDriveUpdate,
db: Session = Depends(get_db),
):
"""
更新網路硬碟
可更新: quota_gb, webdav_url, smb_url, is_active
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
# 更新欄位
update_data = network_drive_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(network_drive, field, value)
db.commit()
db.refresh(network_drive)
# TODO: 創建審計日誌
# TODO: 更新 NAS 配額
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = network_drive.employee.legal_name
response.employee_username = network_drive.employee.username_base
return response
@router.patch("/{network_drive_id}/quota", response_model=NetworkDriveResponse)
def update_network_drive_quota(
network_drive_id: int,
quota_data: NetworkDriveQuotaUpdate,
db: Session = Depends(get_db),
):
"""
更新網路硬碟配額
專用端點,僅更新配額
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
network_drive.quota_gb = quota_data.quota_gb
db.commit()
db.refresh(network_drive)
# TODO: 創建審計日誌
# TODO: 更新 NAS 配額
response = NetworkDriveResponse.model_validate(network_drive)
response.employee_name = network_drive.employee.legal_name
response.employee_username = network_drive.employee.username_base
return response
@router.delete("/{network_drive_id}", response_model=MessageResponse)
def delete_network_drive(
network_drive_id: int,
db: Session = Depends(get_db),
):
"""
停用網路硬碟
注意: 這是軟刪除,只將 is_active 設為 False
"""
network_drive = db.query(NetworkDrive).filter(
NetworkDrive.id == network_drive_id
).first()
if not network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Network drive with id {network_drive_id} not found"
)
network_drive.is_active = False
db.commit()
# TODO: 創建審計日誌
# TODO: 停用 NAS 帳號 (但保留資料)
return MessageResponse(
message=f"Network drive '{network_drive.drive_name}' has been deactivated"
)
@router.get("/by-employee/{employee_id}", response_model=NetworkDriveResponse)
def get_network_drive_by_employee(
employee_id: int,
db: Session = Depends(get_db),
):
"""
根據員工 ID 獲取網路硬碟
"""
employee = db.query(Employee).filter(Employee.id == employee_id).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found"
)
if not employee.network_drive:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee does not have a network drive"
)
response = NetworkDriveResponse.model_validate(employee.network_drive)
response.employee_name = employee.legal_name
response.employee_username = employee.username_base
return response

View File

@@ -0,0 +1,542 @@
"""
系統權限管理 API
管理員工對各系統 (Gitea, Portainer, Traefik, Keycloak) 的存取權限
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_
from app.db.session import get_db
from app.models.employee import Employee
from app.models.permission import Permission
from app.schemas.permission import (
PermissionCreate,
PermissionUpdate,
PermissionResponse,
PermissionListItem,
PermissionBatchCreate,
PermissionFilter,
VALID_SYSTEMS,
VALID_ACCESS_LEVELS,
)
from app.schemas.base import PaginationParams, PaginatedResponse
from app.schemas.response import SuccessResponse, MessageResponse
from app.api.deps import get_pagination_params
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
return 1
@router.get("/", response_model=PaginatedResponse)
def get_permissions(
db: Session = Depends(get_db),
pagination: PaginationParams = Depends(get_pagination_params),
filter_params: PermissionFilter = Depends(),
):
"""
獲取權限列表
支援:
- 分頁
- 員工篩選
- 系統名稱篩選
- 存取層級篩選
"""
tenant_id = get_current_tenant_id()
query = db.query(Permission).filter(Permission.tenant_id == tenant_id)
# 員工篩選
if filter_params.employee_id:
query = query.filter(Permission.employee_id == filter_params.employee_id)
# 系統名稱篩選
if filter_params.system_name:
query = query.filter(Permission.system_name == filter_params.system_name)
# 存取層級篩選
if filter_params.access_level:
query = query.filter(Permission.access_level == filter_params.access_level)
# 總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
permissions = (
query.options(joinedload(Permission.employee))
.offset(offset)
.limit(pagination.page_size)
.all()
)
# 計算總頁數
total_pages = (total + pagination.page_size - 1) // pagination.page_size
# 組裝回應資料
items = []
for perm in permissions:
item = PermissionListItem.model_validate(perm)
item.employee_name = perm.employee.legal_name if perm.employee else None
item.employee_number = perm.employee.employee_id if perm.employee else None
items.append(item)
return PaginatedResponse(
total=total,
page=pagination.page,
page_size=pagination.page_size,
total_pages=total_pages,
items=items,
)
@router.get("/systems", response_model=dict)
def get_available_systems_route():
"""
取得所有可授權的系統列表 (必須在 /{permission_id} 之前定義)
"""
return {
"systems": VALID_SYSTEMS,
"access_levels": VALID_ACCESS_LEVELS,
"system_descriptions": {
"gitea": "Git 程式碼託管系統",
"portainer": "Docker 容器管理系統",
"traefik": "反向代理與路由系統",
"keycloak": "SSO 身份認證系統",
},
"access_level_descriptions": {
"admin": "完整管理權限",
"user": "一般使用者權限",
"readonly": "唯讀權限",
},
}
@router.get("/{permission_id}", response_model=PermissionResponse)
def get_permission(
permission_id: int,
db: Session = Depends(get_db),
):
"""
獲取權限詳情
"""
tenant_id = get_current_tenant_id()
permission = (
db.query(Permission)
.options(
joinedload(Permission.employee),
joinedload(Permission.granter),
)
.filter(
Permission.id == permission_id,
Permission.tenant_id == tenant_id,
)
.first()
)
if not permission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Permission with id {permission_id} not found",
)
# 組裝回應資料
response = PermissionResponse.model_validate(permission)
response.employee_name = permission.employee.legal_name if permission.employee else None
response.employee_number = permission.employee.employee_id if permission.employee else None
response.granted_by_name = permission.granter.legal_name if permission.granter else None
return response
@router.post("/", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED)
def create_permission(
permission_data: PermissionCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
創建權限
注意:
- 員工必須存在
- 每個員工對每個系統只能有一個權限 (unique constraint)
- 系統名稱: gitea, portainer, traefik, keycloak
- 存取層級: admin, user, readonly
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == permission_data.employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {permission_data.employee_id} not found",
)
# 檢查是否已有該系統的權限
existing = db.query(Permission).filter(
and_(
Permission.employee_id == permission_data.employee_id,
Permission.system_name == permission_data.system_name,
Permission.tenant_id == tenant_id,
)
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Permission for system '{permission_data.system_name}' already exists for this employee",
)
# 檢查授予人是否存在 (如果有提供)
if permission_data.granted_by:
granter = db.query(Employee).filter(
Employee.id == permission_data.granted_by,
Employee.tenant_id == tenant_id,
).first()
if not granter:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Granter with id {permission_data.granted_by} not found",
)
# 創建權限
permission = Permission(
tenant_id=tenant_id,
employee_id=permission_data.employee_id,
system_name=permission_data.system_name,
access_level=permission_data.access_level,
granted_by=permission_data.granted_by,
)
db.add(permission)
db.commit()
db.refresh(permission)
# 重新載入關聯資料
db.refresh(permission)
permission = (
db.query(Permission)
.options(
joinedload(Permission.employee),
joinedload(Permission.granter),
)
.filter(Permission.id == permission.id)
.first()
)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="create_permission",
resource_type="permission",
resource_id=permission.id,
details={
"employee_id": permission.employee_id,
"system_name": permission.system_name,
"access_level": permission.access_level,
"granted_by": permission.granted_by,
},
)
# 組裝回應資料
response = PermissionResponse.model_validate(permission)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
response.granted_by_name = permission.granter.legal_name if permission.granter else None
return response
@router.put("/{permission_id}", response_model=PermissionResponse)
def update_permission(
permission_id: int,
permission_data: PermissionUpdate,
request: Request,
db: Session = Depends(get_db),
):
"""
更新權限
可更新:
- 存取層級 (admin, user, readonly)
- 授予人
"""
tenant_id = get_current_tenant_id()
permission = (
db.query(Permission)
.options(
joinedload(Permission.employee),
joinedload(Permission.granter),
)
.filter(
Permission.id == permission_id,
Permission.tenant_id == tenant_id,
)
.first()
)
if not permission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Permission with id {permission_id} not found",
)
# 檢查授予人是否存在 (如果有提供)
if permission_data.granted_by:
granter = db.query(Employee).filter(
Employee.id == permission_data.granted_by,
Employee.tenant_id == tenant_id,
).first()
if not granter:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Granter with id {permission_data.granted_by} not found",
)
# 記錄變更前的值
old_access_level = permission.access_level
old_granted_by = permission.granted_by
# 更新欄位
permission.access_level = permission_data.access_level
if permission_data.granted_by is not None:
permission.granted_by = permission_data.granted_by
db.commit()
db.refresh(permission)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="update_permission",
resource_type="permission",
resource_id=permission.id,
details={
"employee_id": permission.employee_id,
"system_name": permission.system_name,
"changes": {
"access_level": {"from": old_access_level, "to": permission.access_level},
"granted_by": {"from": old_granted_by, "to": permission.granted_by},
},
},
)
# 組裝回應資料
response = PermissionResponse.model_validate(permission)
response.employee_name = permission.employee.legal_name if permission.employee else None
response.employee_number = permission.employee.employee_id if permission.employee else None
response.granted_by_name = permission.granter.legal_name if permission.granter else None
return response
@router.delete("/{permission_id}", response_model=MessageResponse)
def delete_permission(
permission_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
刪除權限
撤銷員工對某系統的存取權限
"""
tenant_id = get_current_tenant_id()
permission = db.query(Permission).filter(
Permission.id == permission_id,
Permission.tenant_id == tenant_id,
).first()
if not permission:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Permission with id {permission_id} not found",
)
# 記錄審計日誌 (在刪除前)
audit_service.log_action(
request=request,
db=db,
action="delete_permission",
resource_type="permission",
resource_id=permission.id,
details={
"employee_id": permission.employee_id,
"system_name": permission.system_name,
"access_level": permission.access_level,
},
)
# 刪除權限
db.delete(permission)
db.commit()
return MessageResponse(
message=f"Permission for system {permission.system_name} has been revoked"
)
@router.get("/employees/{employee_id}/permissions", response_model=List[PermissionResponse])
def get_employee_permissions(
employee_id: int,
db: Session = Depends(get_db),
):
"""
取得員工的所有系統權限
回傳該員工可以存取的所有系統及其權限層級
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {employee_id} not found",
)
# 查詢員工的所有權限
permissions = (
db.query(Permission)
.options(joinedload(Permission.granter))
.filter(
Permission.employee_id == employee_id,
Permission.tenant_id == tenant_id,
)
.all()
)
# 組裝回應資料
result = []
for perm in permissions:
response = PermissionResponse.model_validate(perm)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
response.granted_by_name = perm.granter.legal_name if perm.granter else None
result.append(response)
return result
@router.post("/batch", response_model=List[PermissionResponse], status_code=status.HTTP_201_CREATED)
def create_permissions_batch(
batch_data: PermissionBatchCreate,
request: Request,
db: Session = Depends(get_db),
):
"""
批量創建權限
一次為一個員工授予多個系統的權限
"""
tenant_id = get_current_tenant_id()
# 檢查員工是否存在
employee = db.query(Employee).filter(
Employee.id == batch_data.employee_id,
Employee.tenant_id == tenant_id,
).first()
if not employee:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Employee with id {batch_data.employee_id} not found",
)
# 檢查授予人是否存在 (如果有提供)
granter = None
if batch_data.granted_by:
granter = db.query(Employee).filter(
Employee.id == batch_data.granted_by,
Employee.tenant_id == tenant_id,
).first()
if not granter:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Granter with id {batch_data.granted_by} not found",
)
# 創建權限列表
created_permissions = []
for perm_data in batch_data.permissions:
# 檢查是否已有該系統的權限
existing = db.query(Permission).filter(
and_(
Permission.employee_id == batch_data.employee_id,
Permission.system_name == perm_data.system_name,
Permission.tenant_id == tenant_id,
)
).first()
if existing:
# 跳過已存在的權限
continue
# 創建權限
permission = Permission(
tenant_id=tenant_id,
employee_id=batch_data.employee_id,
system_name=perm_data.system_name,
access_level=perm_data.access_level,
granted_by=batch_data.granted_by,
)
db.add(permission)
created_permissions.append(permission)
db.commit()
# 刷新所有創建的權限
for perm in created_permissions:
db.refresh(perm)
# 記錄審計日誌
audit_service.log_action(
request=request,
db=db,
action="create_permissions_batch",
resource_type="permission",
resource_id=batch_data.employee_id,
details={
"employee_id": batch_data.employee_id,
"granted_by": batch_data.granted_by,
"permissions": [
{
"system_name": perm.system_name,
"access_level": perm.access_level,
}
for perm in created_permissions
],
},
)
# 組裝回應資料
result = []
for perm in created_permissions:
response = PermissionResponse.model_validate(perm)
response.employee_name = employee.legal_name
response.employee_number = employee.employee_id
response.granted_by_name = granter.legal_name if granter else None
result.append(response)
return result

View File

@@ -0,0 +1,326 @@
"""
個人化服務設定 API
記錄員工啟用的個人化服務SSO, Email, Calendar, Drive, Office
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
from app.models.personal_service import PersonalService
from app.schemas.response import MessageResponse
from app.services.audit_service import audit_service
router = APIRouter()
def get_current_tenant_id() -> int:
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
return 1
def get_current_user_id() -> str:
"""取得當前使用者 ID (暫時寫死, 未來從 JWT token sub 取得)"""
return "system-admin"
@router.get("/users/{keycloak_user_id}/services")
def get_user_services(
keycloak_user_id: str,
db: Session = Depends(get_db),
include_inactive: bool = False,
):
"""
取得使用者已啟用的服務列表
Args:
keycloak_user_id: Keycloak User UUID
include_inactive: 是否包含已停用的服務
"""
tenant_id = get_current_tenant_id()
query = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id
)
if not include_inactive:
query = query.filter(
EmpPersonalServiceSetting.is_active == True,
EmpPersonalServiceSetting.disabled_at == None
)
settings = query.all()
result = []
for setting in settings:
service = setting.service
result.append({
"id": setting.id,
"service_id": setting.service_id,
"service_name": service.service_name if service else None,
"service_code": service.service_code if service else None,
"quota_gb": setting.quota_gb,
"quota_mb": setting.quota_mb,
"enabled_at": setting.enabled_at,
"enabled_by": setting.enabled_by,
"disabled_at": setting.disabled_at,
"disabled_by": setting.disabled_by,
"is_active": setting.is_active,
})
return result
@router.post("/users/{keycloak_user_id}/services", status_code=status.HTTP_201_CREATED)
def enable_service_for_user(
keycloak_user_id: str,
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
為使用者啟用個人化服務
Body:
{
"service_id": 4, // 服務 ID (必填)
"quota_gb": 20, // 儲存配額 (Drive 服務用)
"quota_mb": 5120 // 郵件配額 (Email 服務用)
}
"""
tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
service_id = data.get("service_id")
quota_gb = data.get("quota_gb")
quota_mb = data.get("quota_mb")
if not service_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="service_id is required"
)
# 檢查服務是否存在
service = db.query(PersonalService).filter(
PersonalService.id == service_id,
PersonalService.is_active == True
).first()
if not service:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service with id {service_id} not found or inactive"
)
# 檢查是否已經啟用
existing = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.service_id == service_id,
EmpPersonalServiceSetting.is_active == True
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Service {service.service_name} already enabled for this user"
)
# 建立服務設定
setting = EmpPersonalServiceSetting(
tenant_id=tenant_id,
tenant_keycloak_user_id=keycloak_user_id,
service_id=service_id,
quota_gb=quota_gb,
quota_mb=quota_mb,
enabled_at=datetime.utcnow(),
enabled_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(setting)
db.commit()
db.refresh(setting)
# 審計日誌
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="enable_service",
resource_type="emp_personal_service_setting",
resource_id=setting.id,
details=f"Enabled {service.service_name} for user {keycloak_user_id}",
ip_address=request.client.host if request.client else None
)
return {
"id": setting.id,
"service_id": setting.service_id,
"service_name": service.service_name,
"enabled_at": setting.enabled_at,
"quota_gb": setting.quota_gb,
"quota_mb": setting.quota_mb,
}
@router.delete("/users/{keycloak_user_id}/services/{service_id}")
def disable_service_for_user(
keycloak_user_id: str,
service_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""
停用使用者的個人化服務(軟刪除)
"""
tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
# 查詢啟用中的服務設定
setting = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.service_id == service_id,
EmpPersonalServiceSetting.is_active == True
).first()
if not setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Service setting not found or already disabled"
)
# 軟刪除
setting.is_active = False
setting.disabled_at = datetime.utcnow()
setting.disabled_by = current_user
setting.edit_by = current_user
db.commit()
# 審計日誌
service = setting.service
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="disable_service",
resource_type="emp_personal_service_setting",
resource_id=setting.id,
details=f"Disabled {service.service_name if service else service_id} for user {keycloak_user_id}",
ip_address=request.client.host if request.client else None
)
return MessageResponse(message="Service disabled successfully")
@router.post("/users/{keycloak_user_id}/services/batch-enable", status_code=status.HTTP_201_CREATED)
def batch_enable_services(
keycloak_user_id: str,
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
批次啟用所有個人化服務(員工到職時使用)
Body:
{
"storage_quota_gb": 20,
"email_quota_mb": 5120
}
"""
tenant_id = get_current_tenant_id()
current_user = get_current_user_id()
storage_quota_gb = data.get("storage_quota_gb", 20)
email_quota_mb = data.get("email_quota_mb", 5120)
# 取得所有啟用的服務
all_services = db.query(PersonalService).filter(
PersonalService.is_active == True
).all()
enabled_services = []
for service in all_services:
# 檢查是否已啟用
existing = db.query(EmpPersonalServiceSetting).filter(
EmpPersonalServiceSetting.tenant_id == tenant_id,
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
EmpPersonalServiceSetting.service_id == service.id,
EmpPersonalServiceSetting.is_active == True
).first()
if existing:
continue # 已啟用,跳過
# 根據服務類型設定配額
quota_gb = storage_quota_gb if service.service_code == "Drive" else None
quota_mb = email_quota_mb if service.service_code == "Email" else None
setting = EmpPersonalServiceSetting(
tenant_id=tenant_id,
tenant_keycloak_user_id=keycloak_user_id,
service_id=service.id,
quota_gb=quota_gb,
quota_mb=quota_mb,
enabled_at=datetime.utcnow(),
enabled_by=current_user,
is_active=True,
edit_by=current_user
)
db.add(setting)
enabled_services.append(service.service_name)
db.commit()
# 審計日誌
audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=current_user,
action="batch_enable_services",
resource_type="emp_personal_service_setting",
resource_id=None,
details=f"Batch enabled {len(enabled_services)} services for user {keycloak_user_id}: {', '.join(enabled_services)}",
ip_address=request.client.host if request.client else None
)
return {
"enabled_count": len(enabled_services),
"services": enabled_services
}
@router.get("/services")
def get_all_services(
db: Session = Depends(get_db),
include_inactive: bool = False,
):
"""
取得所有可用的個人化服務列表
"""
query = db.query(PersonalService)
if not include_inactive:
query = query.filter(PersonalService.is_active == True)
services = query.all()
return [
{
"id": s.id,
"service_name": s.service_name,
"service_code": s.service_code,
"is_active": s.is_active,
}
for s in services
]

389
backend/app/api/v1/roles.py Normal file
View File

@@ -0,0 +1,389 @@
"""
角色管理 API (RBAC)
- roles: 租戶層級角色 (不綁定部門)
- role_rights: 角色對系統功能的 CRUD 權限
- user_role_assignments: 使用者角色分配 (直接對人,跨部門有效)
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.api.deps import get_tenant_id, get_current_tenant
from app.models.role import UserRole, RoleRight, UserRoleAssignment
from app.models.system_function_cache import SystemFunctionCache
from app.schemas.response import MessageResponse
from app.services.audit_service import audit_service
router = APIRouter()
# ========================
# 角色 CRUD
# ========================
@router.get("/")
def get_roles(
db: Session = Depends(get_db),
include_inactive: bool = False,
):
"""取得租戶的所有角色"""
tenant_id = get_current_tenant_id()
query = db.query(Role).filter(Role.tenant_id == tenant_id)
if not include_inactive:
query = query.filter(Role.is_active == True)
roles = query.order_by(Role.id).all()
return [
{
"id": r.id,
"role_code": r.role_code,
"role_name": r.role_name,
"description": r.description,
"is_active": r.is_active,
"rights_count": len(r.rights),
}
for r in roles
]
@router.get("/{role_id}")
def get_role(
role_id: int,
db: Session = Depends(get_db),
):
"""取得角色詳情(含功能權限)"""
tenant_id = get_current_tenant_id()
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
return {
"id": role.id,
"role_code": role.role_code,
"role_name": role.role_name,
"description": role.description,
"is_active": role.is_active,
"rights": [
{
"function_id": r.function_id,
"function_code": r.function.function_code if r.function else None,
"function_name": r.function.function_name if r.function else None,
"service_code": r.function.service_code if r.function else None,
"can_read": r.can_read,
"can_create": r.can_create,
"can_update": r.can_update,
"can_delete": r.can_delete,
}
for r in role.rights
],
}
@router.post("/", status_code=status.HTTP_201_CREATED)
def create_role(
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
建立角色
Body: { "role_code": "WAREHOUSE_MANAGER", "role_name": "倉管角色", "description": "..." }
"""
tenant_id = get_current_tenant_id()
role_code = data.get("role_code", "").upper()
role_name = data.get("role_name", "")
if not role_code or not role_name:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="role_code and role_name are required"
)
existing = db.query(Role).filter(
Role.tenant_id == tenant_id,
Role.role_code == role_code,
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role code '{role_code}' already exists"
)
role = Role(
tenant_id=tenant_id,
role_code=role_code,
role_name=role_name,
description=data.get("description"),
)
db.add(role)
db.commit()
db.refresh(role)
audit_service.log_action(
request=request, db=db,
action="create_role", resource_type="role", resource_id=role.id,
details={"role_code": role_code, "role_name": role_name},
)
return {"id": role.id, "role_code": role.role_code, "role_name": role.role_name}
@router.delete("/{role_id}", response_model=MessageResponse)
def deactivate_role(
role_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""停用角色"""
tenant_id = get_current_tenant_id()
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
role.is_active = False
db.commit()
return MessageResponse(message=f"Role '{role.role_name}' has been deactivated")
# ========================
# 角色功能權限
# ========================
@router.put("/{role_id}/rights")
def set_role_rights(
role_id: int,
rights: list,
request: Request,
db: Session = Depends(get_db),
):
"""
設定角色的功能權限 (整體替換)
Body: [
{"function_id": 1, "can_read": true, "can_create": false, "can_update": false, "can_delete": false},
...
]
"""
tenant_id = get_current_tenant_id()
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
# 刪除舊的權限
db.query(RoleRight).filter(RoleRight.role_id == role_id).delete()
# 新增新的權限
for r in rights:
function_id = r.get("function_id")
fn = db.query(SystemFunctionCache).filter(
SystemFunctionCache.id == function_id
).first()
if not fn:
continue
right = RoleRight(
role_id=role_id,
function_id=function_id,
can_read=r.get("can_read", False),
can_create=r.get("can_create", False),
can_update=r.get("can_update", False),
can_delete=r.get("can_delete", False),
)
db.add(right)
db.commit()
audit_service.log_action(
request=request, db=db,
action="update_role_rights", resource_type="role", resource_id=role_id,
details={"rights_count": len(rights)},
)
return {"message": f"Role rights updated", "rights_count": len(rights)}
# ========================
# 使用者角色分配
# ========================
@router.get("/user-assignments/")
def get_user_role_assignments(
db: Session = Depends(get_db),
keycloak_user_id: Optional[str] = Query(None, description="Keycloak User UUID"),
):
"""取得使用者角色分配"""
tenant_id = get_current_tenant_id()
query = db.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.is_active == True,
)
if keycloak_user_id:
query = query.filter(UserRoleAssignment.keycloak_user_id == keycloak_user_id)
assignments = query.all()
return [
{
"id": a.id,
"keycloak_user_id": a.keycloak_user_id,
"role_id": a.role_id,
"role_code": a.role.role_code if a.role else None,
"role_name": a.role.role_name if a.role else None,
"assigned_at": a.assigned_at,
}
for a in assignments
]
@router.post("/user-assignments/", status_code=status.HTTP_201_CREATED)
def assign_role_to_user(
data: dict,
request: Request,
db: Session = Depends(get_db),
):
"""
分配角色給使用者 (直接對人,跨部門有效)
Body: { "keycloak_user_id": "uuid", "role_id": 1 }
"""
tenant_id = get_current_tenant_id()
keycloak_user_id = data.get("keycloak_user_id")
role_id = data.get("role_id")
if not keycloak_user_id or not role_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="keycloak_user_id and role_id are required"
)
role = db.query(Role).filter(
Role.id == role_id,
Role.tenant_id == tenant_id,
).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role with id {role_id} not found"
)
existing = db.query(UserRoleAssignment).filter(
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
UserRoleAssignment.role_id == role_id,
).first()
if existing:
if existing.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already has this role assigned"
)
existing.is_active = True
db.commit()
return {"message": "Role assignment reactivated", "id": existing.id}
assignment = UserRoleAssignment(
tenant_id=tenant_id,
keycloak_user_id=keycloak_user_id,
role_id=role_id,
)
db.add(assignment)
db.commit()
db.refresh(assignment)
audit_service.log_action(
request=request, db=db,
action="assign_role", resource_type="user_role_assignment", resource_id=assignment.id,
details={"keycloak_user_id": keycloak_user_id, "role_id": role_id, "role_code": role.role_code},
)
return {"id": assignment.id, "keycloak_user_id": keycloak_user_id, "role_id": role_id}
@router.delete("/user-assignments/{assignment_id}", response_model=MessageResponse)
def revoke_role_from_user(
assignment_id: int,
request: Request,
db: Session = Depends(get_db),
):
"""撤銷使用者角色"""
tenant_id = get_current_tenant_id()
assignment = db.query(UserRoleAssignment).filter(
UserRoleAssignment.id == assignment_id,
UserRoleAssignment.tenant_id == tenant_id,
).first()
if not assignment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Assignment with id {assignment_id} not found"
)
assignment.is_active = False
db.commit()
audit_service.log_action(
request=request, db=db,
action="revoke_role", resource_type="user_role_assignment", resource_id=assignment_id,
details={"keycloak_user_id": assignment.keycloak_user_id, "role_id": assignment.role_id},
)
return MessageResponse(message="Role assignment revoked")
# ========================
# 系統功能查詢
# ========================
@router.get("/system-functions/")
def get_system_functions(
db: Session = Depends(get_db),
service_code: Optional[str] = Query(None, description="服務代碼篩選: hr/erp/mail/ai"),
):
"""取得系統功能清單 (從快取表)"""
query = db.query(SystemFunctionCache).filter(SystemFunctionCache.is_active == True)
if service_code:
query = query.filter(SystemFunctionCache.service_code == service_code)
functions = query.order_by(SystemFunctionCache.service_code, SystemFunctionCache.id).all()
return [
{
"id": f.id,
"service_code": f.service_code,
"function_code": f.function_code,
"function_name": f.function_name,
"function_category": f.function_category,
}
for f in functions
]

View File

@@ -0,0 +1,144 @@
"""
API v1 主路由
"""
from fastapi import APIRouter
from app.api.v1 import (
auth,
tenants,
employees,
departments,
department_members,
roles,
# identities, # Removed: EmployeeIdentity and BusinessUnit models have been deleted
network_drives,
audit_logs,
email_accounts,
permissions,
lifecycle,
personal_service_settings,
emp_onboarding,
system_functions,
)
from app.api.v1.endpoints import installation, installation_phases
api_router = APIRouter()
# 認證
api_router.include_router(
auth.router,
prefix="/auth",
tags=["Authentication"]
)
# 租戶管理 (多租戶核心)
api_router.include_router(
tenants.router,
prefix="/tenants",
tags=["Tenants"]
)
# 員工管理
api_router.include_router(
employees.router,
prefix="/employees",
tags=["Employees"]
)
# 部門管理 (統一樹狀結構,取代原 business-units)
api_router.include_router(
departments.router,
prefix="/departments",
tags=["Departments"]
)
# 部門成員管理 (員工多部門歸屬)
api_router.include_router(
department_members.router,
prefix="/department-members",
tags=["Department Members"]
)
# 角色管理 (RBAC)
api_router.include_router(
roles.router,
prefix="/roles",
tags=["Roles & RBAC"]
)
# 身份管理 (已廢棄 API底層 model 已刪除)
# api_router.include_router(
# identities.router,
# prefix="/identities",
# tags=["Employee Identities (Deprecated)"]
# )
# 網路硬碟管理
api_router.include_router(
network_drives.router,
prefix="/network-drives",
tags=["Network Drives"]
)
# 審計日誌
api_router.include_router(
audit_logs.router,
prefix="/audit-logs",
tags=["Audit Logs"]
)
# 郵件帳號管理
api_router.include_router(
email_accounts.router,
prefix="/email-accounts",
tags=["Email Accounts"]
)
# 系統權限管理
api_router.include_router(
permissions.router,
prefix="/permissions",
tags=["Permissions"]
)
# 員工生命週期管理
api_router.include_router(
lifecycle.router,
prefix="",
tags=["Employee Lifecycle"]
)
# 個人化服務設定管理
api_router.include_router(
personal_service_settings.router,
prefix="/personal-services",
tags=["Personal Service Settings"]
)
# 員工到職/離職流程 (v3.1 多租戶架構)
api_router.include_router(
emp_onboarding.router,
prefix="/emp-lifecycle",
tags=["Employee Onboarding (v3.1)"]
)
# 系統初始化與健康檢查
api_router.include_router(
installation.router,
prefix="/installation",
tags=["Installation & Health Check"]
)
# 系統階段轉換Initialization/Operational/Transition
api_router.include_router(
installation_phases.router,
prefix="/installation",
tags=["System Phase Management"]
)
# 系統功能管理
api_router.include_router(
system_functions.router,
prefix="/system-functions",
tags=["System Functions"]
)

View File

@@ -0,0 +1,303 @@
"""
System Functions API
系統功能明細 CRUD API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from app.db.session import get_db
from app.models.system_function import SystemFunction
from app.schemas.system_function import (
SystemFunctionCreate,
SystemFunctionUpdate,
SystemFunctionResponse,
SystemFunctionListResponse
)
from app.api.deps import get_pagination_params
from app.schemas.base import PaginationParams
router = APIRouter()
@router.get("", response_model=SystemFunctionListResponse)
def get_system_functions(
function_type: Optional[int] = Query(None, description="功能類型 (1:node, 2:function)"),
upper_function_id: Optional[int] = Query(None, description="上層功能代碼"),
is_mana: Optional[bool] = Query(None, description="系統管理"),
is_active: Optional[bool] = Query(None, description="啟用(預設顯示全部)"),
search: Optional[str] = Query(None, description="搜尋 (code or name)"),
pagination: PaginationParams = Depends(get_pagination_params),
db: Session = Depends(get_db),
):
"""
取得系統功能列表
- 支援分頁
- 支援篩選 (function_type, upper_function_id, is_mana, is_active)
- 支援搜尋 (code or name)
"""
query = db.query(SystemFunction)
# 篩選條件
filters = []
if function_type is not None:
filters.append(SystemFunction.function_type == function_type)
if upper_function_id is not None:
filters.append(SystemFunction.upper_function_id == upper_function_id)
if is_mana is not None:
filters.append(SystemFunction.is_mana == is_mana)
if is_active is not None:
filters.append(SystemFunction.is_active == is_active)
# 搜尋
if search:
filters.append(
or_(
SystemFunction.code.ilike(f"%{search}%"),
SystemFunction.name.ilike(f"%{search}%")
)
)
if filters:
query = query.filter(and_(*filters))
# 排序 (依照 order 排序)
query = query.order_by(SystemFunction.order.asc())
# 計算總數
total = query.count()
# 分頁
offset = (pagination.page - 1) * pagination.page_size
items = query.offset(offset).limit(pagination.page_size).all()
return SystemFunctionListResponse(
total=total,
items=items,
page=pagination.page,
page_size=pagination.page_size
)
@router.get("/{function_id}", response_model=SystemFunctionResponse)
def get_system_function(
function_id: int,
db: Session = Depends(get_db),
):
"""
取得單一系統功能
"""
function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
return function
@router.post("", response_model=SystemFunctionResponse, status_code=status.HTTP_201_CREATED)
def create_system_function(
function_in: SystemFunctionCreate,
db: Session = Depends(get_db),
):
"""
建立系統功能
驗證規則:
- function_type=1 (node) 時, module_code 不能輸入
- function_type=2 (function) 時, module_code 和 module_functions 為必填
- upper_function_id 必須是 function_type=1 且 is_active=1 的功能, 或 0 (初始層)
"""
# 驗證 upper_function_id
if function_in.upper_function_id > 0:
parent = db.query(SystemFunction).filter(
SystemFunction.id == function_in.upper_function_id,
SystemFunction.function_type == 1,
SystemFunction.is_active == True
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid upper_function_id: {function_in.upper_function_id} "
"(must be function_type=1 and is_active=1)"
)
# 檢查 code 是否重複
existing = db.query(SystemFunction).filter(SystemFunction.code == function_in.code).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"System function code already exists: {function_in.code}"
)
# 建立資料
db_function = SystemFunction(**function_in.model_dump())
db.add(db_function)
db.commit()
db.refresh(db_function)
return db_function
@router.put("/{function_id}", response_model=SystemFunctionResponse)
def update_system_function(
function_id: int,
function_in: SystemFunctionUpdate,
db: Session = Depends(get_db),
):
"""
更新系統功能 (完整更新)
"""
# 查詢現有資料
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not db_function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
# 更新資料
update_data = function_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_function, field, value)
db.commit()
db.refresh(db_function)
return db_function
@router.patch("/{function_id}", response_model=SystemFunctionResponse)
def patch_system_function(
function_id: int,
function_in: SystemFunctionUpdate,
db: Session = Depends(get_db),
):
"""
更新系統功能 (部分更新)
"""
return update_system_function(function_id, function_in, db)
@router.delete("/{function_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_system_function(
function_id: int,
db: Session = Depends(get_db),
):
"""
刪除系統功能 (實際上是軟刪除, 設定 is_active=False)
"""
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not db_function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
# 軟刪除
db_function.is_active = False
db.commit()
return None
@router.delete("/{function_id}/hard", status_code=status.HTTP_204_NO_CONTENT)
def hard_delete_system_function(
function_id: int,
db: Session = Depends(get_db),
):
"""
永久刪除系統功能 (硬刪除)
⚠️ 警告: 此操作無法復原
"""
db_function = db.query(SystemFunction).filter(SystemFunction.id == function_id).first()
if not db_function:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"System function not found: {function_id}"
)
# 硬刪除
db.delete(db_function)
db.commit()
return None
@router.get("/menu/tree", response_model=List[dict])
def get_menu_tree(
is_sysmana: bool = Query(False, description="是否為系統管理公司"),
db: Session = Depends(get_db),
):
"""
取得功能列表樹狀結構 (用於前端選單顯示)
根據 is_sysmana 過濾功能:
- is_sysmana=true: 返回所有功能 (包含 is_mana=true 的系統管理功能)
- is_sysmana=false: 只返回 is_mana=false 的一般功能
返回格式:
[
{
"id": 10,
"code": "system_managements",
"name": "系統管理後台",
"function_type": 1,
"order": 100,
"function_icon": "",
"module_code": null,
"module_functions": [],
"children": [
{
"id": 11,
"code": "system_settings",
"name": "系統資料設定",
"function_type": 2,
...
}
]
}
]
"""
# 查詢條件
query = db.query(SystemFunction).filter(SystemFunction.is_active == True)
# 如果不是系統管理公司,過濾掉 is_mana=true 的功能
if not is_sysmana:
query = query.filter(SystemFunction.is_mana == False)
# 排序
functions = query.order_by(SystemFunction.order.asc()).all()
# 建立樹狀結構
def build_tree(parent_id: int = 0) -> List[dict]:
tree = []
for func in functions:
if func.upper_function_id == parent_id:
node = {
"id": func.id,
"code": func.code,
"name": func.name,
"function_type": func.function_type,
"order": func.order,
"function_icon": func.function_icon or "",
"module_code": func.module_code,
"module_functions": func.module_functions or [],
"description": func.description or "",
"children": build_tree(func.id) if func.function_type == 1 else []
}
tree.append(node)
return tree
return build_tree(0)

View File

@@ -0,0 +1,603 @@
"""
租戶管理 API
用於管理多租戶資訊(僅系統管理公司可存取)
"""
from typing import List
from datetime import datetime
import secrets
import string
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from app.api.deps import get_db, require_auth, get_current_tenant
from app.models import Tenant, Employee
from app.schemas.tenant import (
TenantCreateRequest,
TenantCreateResponse,
TenantUpdateRequest,
TenantUpdateResponse,
TenantResponse,
InitializationRequest,
InitializationResponse
)
from app.services.keycloak_admin_client import get_keycloak_admin_client
router = APIRouter()
@router.get("/current", summary="取得當前租戶資訊")
def get_current_tenant_info(
tenant: Tenant = Depends(get_current_tenant),
):
"""
取得當前租戶資訊
根據 JWT Token 的 Realm 自動識別租戶
"""
return {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"name_eng": tenant.name_eng,
"tax_id": tenant.tax_id,
"prefix": tenant.prefix,
"tel": tenant.tel,
"add": tenant.add,
"url": tenant.url,
"keycloak_realm": tenant.keycloak_realm,
"is_sysmana": tenant.is_sysmana,
"plan_id": tenant.plan_id,
"max_users": tenant.max_users,
"storage_quota_gb": tenant.storage_quota_gb,
"status": tenant.status,
"is_active": tenant.is_active,
"edit_by": tenant.edit_by,
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
"updated_at": tenant.updated_at.isoformat() if tenant.updated_at else None,
}
@router.patch("/current", summary="更新當前租戶資訊")
def update_current_tenant_info(
request: TenantUpdateRequest,
db: Session = Depends(get_db),
tenant: Tenant = Depends(get_current_tenant),
):
"""
更新當前租戶的基本資料
僅允許更新以下欄位:
- name: 公司名稱
- name_eng: 公司英文名稱
- tax_id: 統一編號
- tel: 公司電話
- add: 公司地址
- url: 公司網站
注意: 租戶代碼 (code)、前綴 (prefix)、方案等核心欄位不可修改
"""
try:
# 更新欄位
if request.name is not None:
tenant.name = request.name
if request.name_eng is not None:
tenant.name_eng = request.name_eng
if request.tax_id is not None:
tenant.tax_id = request.tax_id
if request.tel is not None:
tenant.tel = request.tel
if request.add is not None:
tenant.add = request.add
if request.url is not None:
tenant.url = request.url
# 更新編輯者
tenant.edit_by = "current_user" # TODO: 從 JWT Token 取得實際用戶名稱
db.commit()
db.refresh(tenant)
return {
"message": "公司資料已成功更新",
"tenant": {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"name_eng": tenant.name_eng,
"tax_id": tenant.tax_id,
"tel": tenant.tel,
"add": tenant.add,
"url": tenant.url,
}
}
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"更新失敗: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新失敗: {str(e)}"
)
@router.get("/", summary="列出所有租戶(僅系統管理公司)")
def list_tenants(
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
列出所有租戶
權限要求: 必須為系統管理公司 (is_sysmana=True)
"""
# 權限檢查
if not current_tenant.is_sysmana:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system management company can access this resource"
)
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return {
"total": len(tenants),
"items": [
{
"id": t.id,
"code": t.code,
"name": t.name,
"name_eng": t.name_eng,
"keycloak_realm": t.keycloak_realm,
"tax_id": t.tax_id,
"prefix": t.prefix,
"domain_set": t.domain_set,
"tel": t.tel,
"add": t.add,
"url": t.url,
"plan_id": t.plan_id,
"max_users": t.max_users,
"storage_quota_gb": t.storage_quota_gb,
"status": t.status,
"is_sysmana": t.is_sysmana,
"is_active": t.is_active,
"is_initialized": t.is_initialized,
"initialized_at": t.initialized_at.isoformat() if t.initialized_at else None,
"initialized_by": t.initialized_by,
"edit_by": t.edit_by,
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
}
for t in tenants
],
}
@router.get("/{tenant_id}", summary="取得指定租戶資訊(僅系統管理公司)")
def get_tenant(
tenant_id: int,
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
取得指定租戶詳細資訊
權限要求: 必須為系統管理公司 (is_sysmana=True)
"""
# 權限檢查
if not current_tenant.is_sysmana:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system management company can access this resource"
)
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
)
return {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"name_eng": tenant.name_eng,
"tax_id": tenant.tax_id,
"prefix": tenant.prefix,
"domain_set": tenant.domains,
"tel": tenant.tel,
"add": tenant.add,
"url": tenant.url,
"keycloak_realm": tenant.keycloak_realm,
"is_sysmana": tenant.is_sysmana,
"plan_id": tenant.plan_id,
"max_users": tenant.max_users,
"storage_quota_gb": tenant.storage_quota_gb,
"status": tenant.status,
"is_active": tenant.is_active,
"is_initialized": tenant.is_initialized,
"initialized_at": tenant.initialized_at,
"initialized_by": tenant.initialized_by,
"created_at": tenant.created_at,
"updated_at": tenant.updated_at,
}
def _generate_temp_password(length: int = 12) -> str:
"""產生臨時密碼"""
chars = string.ascii_letters + string.digits + "!@#$%"
return ''.join(secrets.choice(chars) for _ in range(length))
@router.post("/", response_model=TenantCreateResponse, summary="建立新租戶(僅 Superuser")
def create_tenant(
request: TenantCreateRequest,
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
建立新租戶(含 Keycloak Realm + Tenant Admin 帳號)
權限要求: 必須為系統管理公司 (is_sysmana=True)
流程:
1. 驗證租戶代碼唯一性
2. 建立 Keycloak Realm
3. 在 Keycloak Realm 中建立 Tenant Admin 使用者
4. 建立租戶記錄tenants 表)
5. 建立 Employee 記錄employees 表)
6. 返回租戶資訊與臨時密碼
Returns:
租戶資訊 + Tenant Admin 登入資訊
"""
# ========== 權限檢查 ==========
if not current_tenant.is_sysmana:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system management company can create tenants"
)
# ========== Step 1: 驗證租戶代碼唯一性 ==========
existing_tenant = db.query(Tenant).filter(Tenant.code == request.code).first()
if existing_tenant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Tenant code '{request.code}' already exists"
)
# 產生 Keycloak Realm 名稱 (格式: porscheworld-pwd)
realm_name = f"porscheworld-{request.code.lower()}"
# ========== Step 2: 建立 Keycloak Realm ==========
keycloak_client = get_keycloak_admin_client()
realm_config = keycloak_client.create_realm(
realm_name=realm_name,
display_name=request.name
)
if not realm_config:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Keycloak Realm"
)
try:
# ========== Step 3: 建立 Keycloak Realm Role (tenant-admin) ==========
keycloak_client.create_realm_role(
realm_name=realm_name,
role_name="tenant-admin",
description="租戶管理員 - 可管理公司內所有資源"
)
# ========== Step 4: 建立租戶記錄 ==========
new_tenant = Tenant(
code=request.code,
name=request.name,
name_eng=request.name_eng,
tax_id=request.tax_id,
prefix=request.prefix,
tel=request.tel,
add=request.add,
url=request.url,
keycloak_realm=realm_name,
plan_id=request.plan_id,
max_users=request.max_users,
storage_quota_gb=request.storage_quota_gb,
status="trial",
is_sysmana=False,
is_active=True,
is_initialized=False, # 尚未初始化
edit_by="system"
)
db.add(new_tenant)
db.flush() # 取得 tenant.id
# ========== Step 5: 在 Keycloak 建立 Tenant Admin 使用者 ==========
# 使用提供的臨時密碼或產生新的
temp_password = request.admin_temp_password
# 分割姓名 (假設格式: "陳保時" → firstName="保時", lastName="陳")
name_parts = request.admin_name.split()
if len(name_parts) >= 2:
first_name = " ".join(name_parts[1:])
last_name = name_parts[0]
else:
first_name = request.admin_name
last_name = ""
keycloak_user_id = keycloak_client.create_user(
username=request.admin_username,
email=request.admin_email,
first_name=first_name,
last_name=last_name,
enabled=True,
email_verified=False
)
if not keycloak_user_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Keycloak user"
)
# 設定臨時密碼(首次登入必須變更)
keycloak_client.reset_password(
user_id=keycloak_user_id,
password=temp_password,
temporary=True # 臨時密碼
)
# 將 tenant-admin 角色分配給使用者
role_assigned = keycloak_client.assign_realm_role_to_user(
realm_name=realm_name,
user_id=keycloak_user_id,
role_name="tenant-admin"
)
if not role_assigned:
print(f"⚠️ Warning: Failed to assign tenant-admin role to user {keycloak_user_id}")
# 不中斷流程,但記錄警告
# ========== Step 6: 建立 Employee 記錄 ==========
admin_employee = Employee(
tenant_id=new_tenant.id,
seq_no=1, # 第一號員工
tenant_emp_code=f"{request.prefix}0001",
name=request.admin_name,
name_eng=name_parts[0] if len(name_parts) >= 2 else request.admin_name,
keycloak_username=request.admin_username,
keycloak_user_id=keycloak_user_id,
storage_quota_gb=100, # Admin 預設配額
email_quota_mb=10240, # 10 GB
employment_status="active",
is_active=True,
edit_by="system"
)
db.add(admin_employee)
db.commit()
# ========== Step 7: 返回結果 ==========
return TenantCreateResponse(
message="Tenant created successfully",
tenant={
"id": new_tenant.id,
"code": new_tenant.code,
"name": new_tenant.name,
"keycloak_realm": realm_name,
"status": new_tenant.status,
},
admin_user={
"username": request.admin_username,
"email": request.admin_email,
"keycloak_user_id": keycloak_user_id,
},
keycloak_realm=realm_name,
temporary_password=temp_password
)
except IntegrityError as e:
db.rollback()
# 嘗試清理已建立的 Realm
keycloak_client.delete_realm(realm_name)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database integrity error: {str(e)}"
)
except Exception as e:
db.rollback()
# 嘗試清理已建立的 Realm
keycloak_client.delete_realm(realm_name)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create tenant: {str(e)}"
)
@router.post("/{tenant_id}/initialize", response_model=InitializationResponse, summary="完成租戶初始化(僅 Tenant Admin")
def initialize_tenant(
tenant_id: int,
request: InitializationRequest,
db: Session = Depends(get_db),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
完成租戶初始化流程
權限要求:
- 必須為該租戶的成員
- 必須擁有 tenant-admin 角色 (在 Keycloak 驗證)
- 租戶必須尚未初始化 (is_initialized = false)
流程:
1. 驗證權限與初始化狀態
2. 更新公司基本資料
3. 建立部門結構
4. 建立系統角色 (同步到 Keycloak)
5. 儲存預設配額與服務設定
6. 設定 is_initialized = true
7. 記錄審計日誌
Returns:
初始化結果摘要
"""
from app.models import Department, UserRole, AuditLog
# ========== Step 1: 權限檢查 ==========
# 驗證使用者屬於該租戶
if current_tenant.id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only initialize your own tenant"
)
# 取得租戶記錄
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
)
# 防止重複初始化
if tenant.is_initialized:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant has already been initialized. Initialization wizard is locked."
)
# TODO: 驗證使用者擁有 tenant-admin 角色 (從 JWT Token 或 Keycloak API)
# 目前暫時跳過,後續實作 JWT Token 驗證
try:
# ========== Step 2: 更新公司基本資料 ==========
company_info = request.company_info
if "name" in company_info:
tenant.name = company_info["name"]
if "name_eng" in company_info:
tenant.name_eng = company_info["name_eng"]
if "tax_id" in company_info:
tenant.tax_id = company_info["tax_id"]
if "tel" in company_info:
tenant.tel = company_info["tel"]
if "add" in company_info:
tenant.add = company_info["add"]
if "url" in company_info:
tenant.url = company_info["url"]
# ========== Step 3: 建立部門結構 ==========
departments_created = []
for dept_data in request.departments:
new_dept = Department(
tenant_id=tenant_id,
code=dept_data.get("code", dept_data["name"][:10]),
name=dept_data["name"],
name_eng=dept_data.get("name_eng"),
parent_id=dept_data.get("parent_id"),
is_active=True,
edit_by="system"
)
db.add(new_dept)
departments_created.append(dept_data["name"])
db.flush() # 取得部門 ID
# ========== Step 4: 建立系統角色 ==========
keycloak_client = get_keycloak_admin_client()
roles_created = []
for role_data in request.roles:
# 在資料庫建立角色記錄
new_role = UserRole(
tenant_id=tenant_id,
role_code=role_data["code"],
role_name=role_data["name"],
description=role_data.get("description", ""),
is_active=True,
edit_by="system"
)
db.add(new_role)
# 在 Keycloak Realm 建立對應角色
role_created = keycloak_client.create_realm_role(
realm_name=tenant.keycloak_realm,
role_name=role_data["code"],
description=role_data.get("description", role_data["name"])
)
if role_created:
roles_created.append(role_data["name"])
else:
print(f"⚠️ Warning: Failed to create role {role_data['code']} in Keycloak")
# ========== Step 5: 儲存預設配額與服務設定 ==========
# TODO: 實作預設配額儲存邏輯 (需要設計 tenant_settings 表)
# 目前暫時儲存在 tenant 的 JSONB 欄位或獨立表
default_settings = request.default_settings
# 這裡可以儲存到 tenant metadata 或獨立的 settings 表
# ========== Step 6: 設定初始化完成 ==========
tenant.is_initialized = True
tenant.initialized_at = datetime.utcnow()
# TODO: 從 JWT Token 取得 current_user.username
tenant.initialized_by = "admin" # 暫時硬編碼
# ========== Step 7: 記錄審計日誌 ==========
audit_log = AuditLog(
tenant_id=tenant_id,
user_id=None, # TODO: 從 current_user 取得
action="tenant.initialized",
resource_type="tenant",
resource_id=str(tenant_id),
details={
"departments_created": len(departments_created),
"roles_created": len(roles_created),
"department_names": departments_created,
"role_names": roles_created,
"default_settings": default_settings,
},
ip_address=None, # TODO: 從 request 取得
user_agent=None,
)
db.add(audit_log)
# 提交所有變更
db.commit()
# ========== Step 8: 返回結果 ==========
return InitializationResponse(
message="Tenant initialization completed successfully",
summary={
"tenant_id": tenant_id,
"tenant_name": tenant.name,
"departments_created": len(departments_created),
"roles_created": len(roles_created),
"initialized_at": tenant.initialized_at.isoformat(),
"initialized_by": tenant.initialized_by,
}
)
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database integrity error: {str(e)}"
)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Initialization failed: {str(e)}"
)

View File

@@ -0,0 +1,4 @@
"""
批次作業模組
包含所有定時排程的批次處理任務
"""

View File

@@ -0,0 +1,160 @@
"""
審計日誌歸檔批次 (5.3)
執行時間: 每月 1 日 01:00
批次名稱: archive_audit_logs
將 90 天前的審計日誌匯出為 CSV並從主資料庫刪除
歸檔目錄: /mnt/nas/working/audit_logs/
"""
import csv
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from app.batch.base import log_batch_execution
logger = logging.getLogger(__name__)
ARCHIVE_DAYS = 90 # 保留最近 90 天,超過的歸檔
ARCHIVE_BASE_DIR = "/mnt/nas/working/audit_logs"
def _get_archive_dir() -> str:
"""取得歸檔目錄,不存在時建立"""
os.makedirs(ARCHIVE_BASE_DIR, exist_ok=True)
return ARCHIVE_BASE_DIR
def run_archive_audit_logs(dry_run: bool = False) -> dict:
"""
執行審計日誌歸檔批次
Args:
dry_run: True 時只統計不實際刪除
Returns:
執行結果摘要
"""
started_at = datetime.utcnow()
cutoff_date = datetime.utcnow() - timedelta(days=ARCHIVE_DAYS)
logger.info(f"=== 開始審計日誌歸檔批次 === 截止日期: {cutoff_date.strftime('%Y-%m-%d')}")
if dry_run:
logger.info("[DRY RUN] 不會實際刪除資料")
from app.db.session import get_db
from app.models.audit_log import AuditLog
db = next(get_db())
try:
# 1. 查詢超過 90 天的日誌
old_logs = db.query(AuditLog).filter(
AuditLog.performed_at < cutoff_date
).order_by(AuditLog.performed_at).all()
total_count = len(old_logs)
logger.info(f"找到 {total_count} 筆待歸檔日誌")
if total_count == 0:
message = f"無需歸檔 (截止日期 {cutoff_date.strftime('%Y-%m-%d')} 前無記錄)"
log_batch_execution(
batch_name="archive_audit_logs",
status="success",
message=message,
started_at=started_at,
)
return {"status": "success", "archived": 0, "message": message}
# 2. 匯出到 CSV
archive_month = cutoff_date.strftime("%Y%m")
archive_dir = _get_archive_dir()
csv_path = os.path.join(archive_dir, f"archive_{archive_month}.csv")
fieldnames = [
"id", "action", "resource_type", "resource_id",
"performed_by", "ip_address",
"details", "performed_at"
]
logger.info(f"匯出至: {csv_path}")
with open(csv_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for log in old_logs:
writer.writerow({
"id": log.id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"performed_by": getattr(log, "performed_by", ""),
"ip_address": getattr(log, "ip_address", ""),
"details": str(getattr(log, "details", "")),
"performed_at": str(log.performed_at),
})
logger.info(f"已匯出 {total_count} 筆至 {csv_path}")
# 3. 刪除舊日誌 (非 dry_run 才執行)
deleted_count = 0
if not dry_run:
for log in old_logs:
db.delete(log)
db.commit()
deleted_count = total_count
logger.info(f"已刪除 {deleted_count} 筆舊日誌")
else:
logger.info(f"[DRY RUN] 將刪除 {total_count} 筆 (未實際執行)")
# 4. 記錄批次執行日誌
finished_at = datetime.utcnow()
message = (
f"歸檔 {total_count} 筆到 {csv_path}"
+ (f"; 已刪除 {deleted_count}" if not dry_run else " (DRY RUN)")
)
log_batch_execution(
batch_name="archive_audit_logs",
status="success",
message=message,
started_at=started_at,
finished_at=finished_at,
)
logger.info(f"=== 審計日誌歸檔批次完成 === {message}")
return {
"status": "success",
"archived": total_count,
"deleted": deleted_count,
"csv_path": csv_path,
}
except Exception as e:
error_msg = f"審計日誌歸檔批次失敗: {str(e)}"
logger.error(error_msg)
try:
db.rollback()
except Exception:
pass
log_batch_execution(
batch_name="archive_audit_logs",
status="failed",
message=error_msg,
started_at=started_at,
)
return {"status": "failed", "error": str(e)}
finally:
db.close()
if __name__ == "__main__":
import sys
import argparse
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
logging.basicConfig(level=logging.INFO)
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="只統計不實際刪除")
args = parser.parse_args()
result = run_archive_audit_logs(dry_run=args.dry_run)
print(f"執行結果: {result}")

59
backend/app/batch/base.py Normal file
View File

@@ -0,0 +1,59 @@
"""
批次作業基礎工具
提供 log_batch_execution 等共用函式
"""
import logging
from datetime import datetime
from typing import Optional
logger = logging.getLogger(__name__)
def log_batch_execution(
batch_name: str,
status: str,
message: Optional[str] = None,
started_at: Optional[datetime] = None,
finished_at: Optional[datetime] = None,
) -> None:
"""
記錄批次執行日誌到資料庫
Args:
batch_name: 批次名稱
status: 執行狀態 (success/failed/warning)
message: 執行訊息
started_at: 開始時間 (若未提供則使用 finished_at)
finished_at: 完成時間 (若未提供則使用現在)
"""
from app.db.session import get_db
from app.models.batch_log import BatchLog
now = datetime.utcnow()
finished = finished_at or now
started = started_at or finished
duration = None
if started and finished:
duration = int((finished - started).total_seconds())
try:
db = next(get_db())
log_entry = BatchLog(
batch_name=batch_name,
status=status,
message=message,
started_at=started,
finished_at=finished,
duration_seconds=duration,
)
db.add(log_entry)
db.commit()
logger.info(f"[{batch_name}] 批次執行記錄已寫入: {status}")
except Exception as e:
logger.error(f"[{batch_name}] 寫入批次日誌失敗: {e}")
finally:
try:
db.close()
except Exception:
pass

View File

@@ -0,0 +1,152 @@
"""
每日配額檢查批次 (5.1)
執行時間: 每日 02:00
批次名稱: daily_quota_check
檢查郵件和雲端硬碟配額使用情況,超過 80% 發送告警
"""
import logging
from datetime import datetime
from app.batch.base import log_batch_execution
logger = logging.getLogger(__name__)
QUOTA_ALERT_THRESHOLD = 0.8 # 超過 80% 發送告警
ALERT_EMAIL = "admin@porscheworld.tw"
def _send_alert_email(to: str, subject: str, body: str) -> bool:
"""
發送告警郵件
目前使用 SMTP 直送,未來可整合 Mailserver
"""
try:
import smtplib
from email.mime.text import MIMEText
from app.core.config import settings
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = settings.MAIL_ADMIN_USER
msg["To"] = to
with smtplib.SMTP(settings.MAIL_SERVER, settings.MAIL_PORT) as smtp:
if settings.MAIL_USE_TLS:
smtp.starttls()
smtp.login(settings.MAIL_ADMIN_USER, settings.MAIL_ADMIN_PASSWORD)
smtp.send_message(msg)
logger.info(f"告警郵件已發送至 {to}: {subject}")
return True
except Exception as e:
logger.warning(f"發送告警郵件失敗: {e}")
return False
def run_daily_quota_check() -> dict:
"""
執行每日配額檢查批次
Returns:
執行結果摘要
"""
started_at = datetime.utcnow()
alerts_sent = 0
errors = []
summary = {
"email_checked": 0,
"email_alerts": 0,
"drive_checked": 0,
"drive_alerts": 0,
}
logger.info("=== 開始每日配額檢查批次 ===")
# 取得資料庫 Session
from app.db.session import get_db
from app.models.email_account import EmailAccount
from app.models.network_drive import NetworkDrive
db = next(get_db())
try:
# 1. 檢查郵件配額
logger.info("檢查郵件配額使用情況...")
email_accounts = db.query(EmailAccount).filter(
EmailAccount.is_active == True
).all()
for account in email_accounts:
summary["email_checked"] += 1
# 目前郵件 Mailserver API 未整合,跳過實際配額查詢
# TODO: 整合 Mailserver API 後取得實際使用量
# usage_mb = mailserver_service.get_usage(account.email_address)
# if usage_mb and usage_mb / account.quota_mb > QUOTA_ALERT_THRESHOLD:
# _send_alert_email(...)
pass
logger.info(f"郵件帳號檢查完成: {summary['email_checked']} 個帳號")
# 2. 檢查雲端硬碟配額 (Drive Service API)
logger.info("檢查雲端硬碟配額使用情況...")
network_drives = db.query(NetworkDrive).filter(
NetworkDrive.is_active == True
).all()
from app.services.drive_service import get_drive_service_client
drive_client = get_drive_service_client()
for drive in network_drives:
summary["drive_checked"] += 1
try:
# 查詢配額使用量 (Drive Service 未上線時會回傳 None)
# 注意: drive.id 是資料庫 ID需要 drive_user_id
# 目前跳過實際查詢,等 Drive Service 上線後補充
pass
except Exception as e:
logger.warning(f"查詢 {drive.drive_name} 配額失敗: {e}")
logger.info(f"雲端硬碟檢查完成: {summary['drive_checked']} 個帳號")
# 3. 記錄批次執行日誌
finished_at = datetime.utcnow()
message = (
f"郵件帳號: {summary['email_checked']} 個, 告警: {summary['email_alerts']} 個; "
f"雲端硬碟: {summary['drive_checked']} 個, 告警: {summary['drive_alerts']}"
)
log_batch_execution(
batch_name="daily_quota_check",
status="success",
message=message,
started_at=started_at,
finished_at=finished_at,
)
logger.info(f"=== 每日配額檢查批次完成 === {message}")
return {"status": "success", "summary": summary}
except Exception as e:
error_msg = f"每日配額檢查批次失敗: {str(e)}"
logger.error(error_msg)
log_batch_execution(
batch_name="daily_quota_check",
status="failed",
message=error_msg,
started_at=started_at,
)
return {"status": "failed", "error": str(e)}
finally:
db.close()
if __name__ == "__main__":
import sys
import os
# 允許直接執行此批次
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
logging.basicConfig(level=logging.INFO)
result = run_daily_quota_check()
print(f"執行結果: {result}")

View File

@@ -0,0 +1,103 @@
"""
批次作業排程器 (5.4)
使用 schedule 套件管理所有批次排程
排程清單:
- 每日 00:00 - auto_terminate_employees (未來實作)
- 每日 02:00 - daily_quota_check
- 每日 03:00 - sync_keycloak_users
- 每月 1 日 01:00 - archive_audit_logs
啟動方式:
python -m app.batch.scheduler
"""
import logging
import signal
import sys
import time
from datetime import datetime
logger = logging.getLogger(__name__)
def _setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def _run_daily_quota_check():
logger.info("觸發: 每日配額檢查批次")
try:
from app.batch.daily_quota_check import run_daily_quota_check
result = run_daily_quota_check()
logger.info(f"每日配額檢查批次完成: {result.get('status')}")
except Exception as e:
logger.error(f"每日配額檢查批次異常: {e}")
def _run_sync_keycloak_users():
logger.info("觸發: Keycloak 同步批次")
try:
from app.batch.sync_keycloak_users import run_sync_keycloak_users
result = run_sync_keycloak_users()
logger.info(f"Keycloak 同步批次完成: {result.get('status')}")
except Exception as e:
logger.error(f"Keycloak 同步批次異常: {e}")
def _run_archive_audit_logs():
"""只在每月 1 日執行"""
if datetime.now().day != 1:
return
logger.info("觸發: 審計日誌歸檔批次 (每月 1 日)")
try:
from app.batch.archive_audit_logs import run_archive_audit_logs
result = run_archive_audit_logs()
logger.info(f"審計日誌歸檔批次完成: {result.get('status')}")
except Exception as e:
logger.error(f"審計日誌歸檔批次異常: {e}")
def start_scheduler():
"""啟動排程器"""
try:
import schedule
except ImportError:
logger.error("缺少 schedule 套件,請執行: pip install schedule")
sys.exit(1)
logger.info("=== HR Portal 批次排程器啟動 ===")
# 每日 02:00 - 配額檢查
schedule.every().day.at("02:00").do(_run_daily_quota_check)
# 每日 03:00 - Keycloak 同步
schedule.every().day.at("03:00").do(_run_sync_keycloak_users)
# 每日 01:00 - 審計日誌歸檔 (函式內部判斷是否為每月 1 日)
schedule.every().day.at("01:00").do(_run_archive_audit_logs)
logger.info("排程設定完成:")
logger.info(" 02:00 - 每日配額檢查")
logger.info(" 03:00 - Keycloak 同步")
logger.info(" 01:00 - 審計日誌歸檔 (每月 1 日)")
# 處理 SIGTERM (Docker 停止信號)
def handle_sigterm(signum, frame):
logger.info("收到停止信號,排程器正在關閉...")
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
logger.info("排程器運行中,等待任務觸發...")
while True:
schedule.run_pending()
time.sleep(60) # 每分鐘檢查一次
if __name__ == "__main__":
_setup_logging()
start_scheduler()

View File

@@ -0,0 +1,146 @@
"""
Keycloak 同步批次 (5.2)
執行時間: 每日 03:00
批次名稱: sync_keycloak_users
同步 Keycloak 使用者狀態到 HR Portal
以 HR Portal 為準 (Single Source of Truth)
"""
import logging
from datetime import datetime
from app.batch.base import log_batch_execution
logger = logging.getLogger(__name__)
def run_sync_keycloak_users() -> dict:
"""
執行 Keycloak 同步批次
以 HR Portal 員工狀態為準,同步到 Keycloak:
- active → Keycloak enabled = True
- terminated/on_leave → Keycloak enabled = False
Returns:
執行結果摘要
"""
started_at = datetime.utcnow()
summary = {
"total_checked": 0,
"synced": 0,
"not_found_in_keycloak": 0,
"no_keycloak_id": 0,
"errors": 0,
}
issues = []
logger.info("=== 開始 Keycloak 同步批次 ===")
from app.db.session import get_db
from app.models.employee import Employee
from app.services.keycloak_admin_client import get_keycloak_admin_client
db = next(get_db())
try:
# 1. 取得所有員工
employees = db.query(Employee).all()
keycloak_client = get_keycloak_admin_client()
logger.info(f"{len(employees)} 位員工待檢查")
for emp in employees:
summary["total_checked"] += 1
# 跳過沒有 Keycloak ID 的員工 (尚未執行到職流程)
# 以 username_base 查詢 Keycloak
username = emp.username_base
if not username:
summary["no_keycloak_id"] += 1
continue
try:
# 2. 查詢 Keycloak 使用者
kc_user = keycloak_client.get_user_by_username(username)
if not kc_user:
# Keycloak 使用者不存在,可能尚未建立
summary["not_found_in_keycloak"] += 1
logger.debug(f"員工 {emp.employee_id} ({username}) 在 Keycloak 中不存在,跳過")
continue
kc_user_id = kc_user.get("id")
kc_enabled = kc_user.get("enabled", False)
# 3. 判斷應有的 enabled 狀態
should_be_enabled = (emp.status == "active")
# 4. 狀態不一致時,以 HR Portal 為準同步到 Keycloak
if kc_enabled != should_be_enabled:
success = keycloak_client.update_user(
kc_user_id, {"enabled": should_be_enabled}
)
if success:
summary["synced"] += 1
logger.info(
f"✓ 同步 {emp.employee_id} ({username}): "
f"Keycloak enabled {kc_enabled}{should_be_enabled} "
f"(HR 狀態: {emp.status})"
)
else:
summary["errors"] += 1
issues.append(f"{emp.employee_id}: 同步失敗")
logger.warning(f"✗ 同步 {emp.employee_id} ({username}) 失敗")
except Exception as e:
summary["errors"] += 1
issues.append(f"{emp.employee_id}: {str(e)}")
logger.error(f"處理員工 {emp.employee_id} 時發生錯誤: {e}")
# 5. 記錄批次執行日誌
finished_at = datetime.utcnow()
message = (
f"檢查: {summary['total_checked']}, "
f"同步: {summary['synced']}, "
f"Keycloak 無帳號: {summary['not_found_in_keycloak']}, "
f"錯誤: {summary['errors']}"
)
if issues:
message += f"\n問題清單: {'; '.join(issues[:10])}"
if len(issues) > 10:
message += f" ... 共 {len(issues)} 個問題"
status = "failed" if summary["errors"] > 0 else "success"
log_batch_execution(
batch_name="sync_keycloak_users",
status=status,
message=message,
started_at=started_at,
finished_at=finished_at,
)
logger.info(f"=== Keycloak 同步批次完成 === {message}")
return {"status": status, "summary": summary}
except Exception as e:
error_msg = f"Keycloak 同步批次失敗: {str(e)}"
logger.error(error_msg)
log_batch_execution(
batch_name="sync_keycloak_users",
status="failed",
message=error_msg,
started_at=started_at,
)
return {"status": "failed", "error": str(e)}
finally:
db.close()
if __name__ == "__main__":
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
logging.basicConfig(level=logging.INFO)
result = run_sync_keycloak_users()
print(f"執行結果: {result}")

View File

136
backend/app/core/audit.py Normal file
View File

@@ -0,0 +1,136 @@
"""
審計日誌裝飾器和工具函數
"""
from functools import wraps
from typing import Callable, Optional
from fastapi import Request
from sqlalchemy.orm import Session
def get_current_username() -> str:
"""
獲取當前用戶名稱
TODO: 實作後從 JWT Token 獲取
目前返回系統用戶
"""
# TODO: 從 Keycloak JWT Token 解析用戶名
return "system@porscheworld.tw"
def audit_log_decorator(
action: str,
resource_type: str,
get_resource_id: Optional[Callable] = None,
get_details: Optional[Callable] = None,
):
"""
審計日誌裝飾器
使用範例:
@audit_log_decorator(
action="create",
resource_type="employee",
get_resource_id=lambda result: result.id,
get_details=lambda result: {"employee_id": result.employee_id}
)
def create_employee(...):
pass
Args:
action: 操作類型
resource_type: 資源類型
get_resource_id: 從返回結果獲取資源 ID 的函數
get_details: 從返回結果獲取詳細資訊的函數
"""
def decorator(func: Callable):
@wraps(func)
async def async_wrapper(*args, **kwargs):
# 執行原函數
result = await func(*args, **kwargs)
# 獲取 DB Session
db: Optional[Session] = kwargs.get("db")
if not db:
return result
# 獲取 Request (用於 IP)
request: Optional[Request] = kwargs.get("request")
ip_address = None
if request:
from app.services.audit_service import audit_service
ip_address = audit_service.get_client_ip(request)
# 獲取資源 ID
resource_id = None
if get_resource_id and result:
resource_id = get_resource_id(result)
# 獲取詳細資訊
details = None
if get_details and result:
details = get_details(result)
# 記錄審計日誌
from app.services.audit_service import audit_service
audit_service.log(
db=db,
action=action,
resource_type=resource_type,
resource_id=resource_id,
performed_by=get_current_username(),
details=details,
ip_address=ip_address,
)
return result
@wraps(func)
def sync_wrapper(*args, **kwargs):
# 執行原函數
result = func(*args, **kwargs)
# 獲取 DB Session
db: Optional[Session] = kwargs.get("db")
if not db:
return result
# 獲取 Request (用於 IP)
request: Optional[Request] = kwargs.get("request")
ip_address = None
if request:
from app.services.audit_service import audit_service
ip_address = audit_service.get_client_ip(request)
# 獲取資源 ID
resource_id = None
if get_resource_id and result:
resource_id = get_resource_id(result)
# 獲取詳細資訊
details = None
if get_details and result:
details = get_details(result)
# 記錄審計日誌
from app.services.audit_service import audit_service
audit_service.log(
db=db,
action=action,
resource_type=resource_type,
resource_id=resource_id,
performed_by=get_current_username(),
details=details,
ip_address=ip_address,
)
return result
# 檢查是否為異步函數
import asyncio
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator

View File

@@ -0,0 +1,92 @@
"""簡化配置 - 用於測試"""
from pydantic_settings import BaseSettings
import os
from dotenv import load_dotenv
# 載入 .env 檔案 (必須在讀取環境變數之前)
load_dotenv()
# 直接從環境變數讀取,不依賴 pydantic-settings 的複雜功能
class Settings:
"""應用配置 (簡化版)"""
# 基本資訊
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "HR Portal API")
VERSION: str = os.getenv("VERSION", "2.0.0")
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
# 資料庫
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal")
DATABASE_ECHO: bool = os.getenv("DATABASE_ECHO", "False").lower() == "true"
# CORS
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:10180,http://10.1.0.245:3000,http://10.1.0.245:10180,https://hr.ease.taipei")
def get_allowed_origins(self):
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
# Keycloak
KEYCLOAK_URL: str = os.getenv("KEYCLOAK_URL", "https://auth.ease.taipei")
KEYCLOAK_REALM: str = os.getenv("KEYCLOAK_REALM", "porscheworld")
KEYCLOAK_CLIENT_ID: str = os.getenv("KEYCLOAK_CLIENT_ID", "hr-backend")
KEYCLOAK_CLIENT_SECRET: str = os.getenv("KEYCLOAK_CLIENT_SECRET", "")
KEYCLOAK_ADMIN_USERNAME: str = os.getenv("KEYCLOAK_ADMIN_USERNAME", "")
KEYCLOAK_ADMIN_PASSWORD: str = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "")
# JWT
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
# 郵件
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "10.1.0.30")
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "True").lower() == "true"
MAIL_ADMIN_USER: str = os.getenv("MAIL_ADMIN_USER", "admin@porscheworld.tw")
MAIL_ADMIN_PASSWORD: str = os.getenv("MAIL_ADMIN_PASSWORD", "")
# NAS
NAS_HOST: str = os.getenv("NAS_HOST", "10.1.0.30")
NAS_PORT: int = int(os.getenv("NAS_PORT", "5000"))
NAS_USERNAME: str = os.getenv("NAS_USERNAME", "")
NAS_PASSWORD: str = os.getenv("NAS_PASSWORD", "")
NAS_WEBDAV_URL: str = os.getenv("NAS_WEBDAV_URL", "https://nas.lab.taipei/webdav")
NAS_SMB_SHARE: str = os.getenv("NAS_SMB_SHARE", "Working")
# 日誌
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
LOG_FILE: str = os.getenv("LOG_FILE", "logs/hr_portal.log")
# 分頁
DEFAULT_PAGE_SIZE: int = int(os.getenv("DEFAULT_PAGE_SIZE", "20"))
MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
# 郵件配額 (MB)
EMAIL_QUOTA_JUNIOR: int = int(os.getenv("EMAIL_QUOTA_JUNIOR", "1000"))
EMAIL_QUOTA_MID: int = int(os.getenv("EMAIL_QUOTA_MID", "2000"))
EMAIL_QUOTA_SENIOR: int = int(os.getenv("EMAIL_QUOTA_SENIOR", "5000"))
EMAIL_QUOTA_MANAGER: int = int(os.getenv("EMAIL_QUOTA_MANAGER", "10000"))
# NAS 配額 (GB)
NAS_QUOTA_JUNIOR: int = int(os.getenv("NAS_QUOTA_JUNIOR", "50"))
NAS_QUOTA_MID: int = int(os.getenv("NAS_QUOTA_MID", "100"))
NAS_QUOTA_SENIOR: int = int(os.getenv("NAS_QUOTA_SENIOR", "200"))
NAS_QUOTA_MANAGER: int = int(os.getenv("NAS_QUOTA_MANAGER", "500"))
# Drive Service (Nextcloud 微服務)
DRIVE_SERVICE_URL: str = os.getenv("DRIVE_SERVICE_URL", "https://drive-api.ease.taipei")
DRIVE_SERVICE_TIMEOUT: int = int(os.getenv("DRIVE_SERVICE_TIMEOUT", "10"))
DRIVE_SERVICE_TENANT_ID: int = int(os.getenv("DRIVE_SERVICE_TENANT_ID", "1"))
# Docker Mailserver SSH 整合
MAILSERVER_SSH_HOST: str = os.getenv("MAILSERVER_SSH_HOST", "10.1.0.254")
MAILSERVER_SSH_PORT: int = int(os.getenv("MAILSERVER_SSH_PORT", "22"))
MAILSERVER_SSH_USER: str = os.getenv("MAILSERVER_SSH_USER", "porsche")
MAILSERVER_SSH_PASSWORD: str = os.getenv("MAILSERVER_SSH_PASSWORD", "")
MAILSERVER_CONTAINER_NAME: str = os.getenv("MAILSERVER_CONTAINER_NAME", "mailserver")
MAILSERVER_SSH_TIMEOUT: int = int(os.getenv("MAILSERVER_SSH_TIMEOUT", "30"))
# 創建實例
settings = Settings()

View File

@@ -0,0 +1,87 @@
"""
應用配置管理
使用 Pydantic Settings 管理環境變數
"""
from typing import List, Union
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""應用配置"""
# 基本資訊
PROJECT_NAME: str = "HR Portal API"
VERSION: str = "2.0.0"
ENVIRONMENT: str = "development" # development, staging, production
HOST: str = "0.0.0.0"
PORT: int = 8000
# 資料庫配置 (使用 psycopg 驅動)
DATABASE_URL: str = "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
DATABASE_ECHO: bool = False # SQL 查詢日誌
# CORS 配置 (字串格式,逗號分隔)
ALLOWED_ORIGINS: str = "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
def get_allowed_origins(self) -> List[str]:
"""取得 CORS 允許的來源清單"""
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
# Keycloak 配置
KEYCLOAK_URL: str = "https://auth.ease.taipei"
KEYCLOAK_REALM: str = "porscheworld"
KEYCLOAK_CLIENT_ID: str = "hr-backend"
KEYCLOAK_CLIENT_SECRET: str = "" # 從環境變數讀取
KEYCLOAK_ADMIN_USERNAME: str = ""
KEYCLOAK_ADMIN_PASSWORD: str = ""
# JWT 配置
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# 郵件配置 (Docker Mailserver)
MAIL_SERVER: str = "10.1.0.30"
MAIL_PORT: int = 587
MAIL_USE_TLS: bool = True
MAIL_ADMIN_USER: str = "admin@porscheworld.tw"
MAIL_ADMIN_PASSWORD: str = ""
# NAS 配置 (Synology)
NAS_HOST: str = "10.1.0.30"
NAS_PORT: int = 5000
NAS_USERNAME: str = ""
NAS_PASSWORD: str = ""
NAS_WEBDAV_URL: str = "https://nas.lab.taipei/webdav"
NAS_SMB_SHARE: str = "Working"
# 日誌配置
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/hr_portal.log"
# 分頁配置
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
# 配額配置 (MB)
EMAIL_QUOTA_JUNIOR: int = 1000
EMAIL_QUOTA_MID: int = 2000
EMAIL_QUOTA_SENIOR: int = 5000
EMAIL_QUOTA_MANAGER: int = 10000
# NAS 配額配置 (GB)
NAS_QUOTA_JUNIOR: int = 50
NAS_QUOTA_MID: int = 100
NAS_QUOTA_SENIOR: int = 200
NAS_QUOTA_MANAGER: int = 500
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
)
# 全域配置實例
settings = Settings()

View File

@@ -0,0 +1,94 @@
"""
應用配置管理
使用 Pydantic Settings 管理環境變數
"""
from typing import List, Union
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from dotenv import load_dotenv
import os
# 手動載入 .env 檔案 (避免網路磁碟 I/O 延遲問題)
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if os.path.exists(env_path):
load_dotenv(env_path)
class Settings(BaseSettings):
"""應用配置"""
# 基本資訊
PROJECT_NAME: str = "HR Portal API"
VERSION: str = "2.0.0"
ENVIRONMENT: str = "development" # development, staging, production
HOST: str = "0.0.0.0"
PORT: int = 8000
# 資料庫配置 (使用 psycopg 驅動)
DATABASE_URL: str = "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal"
DATABASE_ECHO: bool = False # SQL 查詢日誌
# CORS 配置 (字串格式,逗號分隔)
ALLOWED_ORIGINS: str = "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei"
def get_allowed_origins(self) -> List[str]:
"""取得 CORS 允許的來源清單"""
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
# Keycloak 配置
KEYCLOAK_URL: str = "https://auth.ease.taipei"
KEYCLOAK_REALM: str = "porscheworld"
KEYCLOAK_CLIENT_ID: str = "hr-backend"
KEYCLOAK_CLIENT_SECRET: str = "" # 從環境變數讀取
KEYCLOAK_ADMIN_USERNAME: str = ""
KEYCLOAK_ADMIN_PASSWORD: str = ""
# JWT 配置
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# 郵件配置 (Docker Mailserver)
MAIL_SERVER: str = "10.1.0.30"
MAIL_PORT: int = 587
MAIL_USE_TLS: bool = True
MAIL_ADMIN_USER: str = "admin@porscheworld.tw"
MAIL_ADMIN_PASSWORD: str = ""
# NAS 配置 (Synology)
NAS_HOST: str = "10.1.0.30"
NAS_PORT: int = 5000
NAS_USERNAME: str = ""
NAS_PASSWORD: str = ""
NAS_WEBDAV_URL: str = "https://nas.lab.taipei/webdav"
NAS_SMB_SHARE: str = "Working"
# 日誌配置
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/hr_portal.log"
# 分頁配置
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
# 配額配置 (MB)
EMAIL_QUOTA_JUNIOR: int = 1000
EMAIL_QUOTA_MID: int = 2000
EMAIL_QUOTA_SENIOR: int = 5000
EMAIL_QUOTA_MANAGER: int = 10000
# NAS 配額配置 (GB)
NAS_QUOTA_JUNIOR: int = 50
NAS_QUOTA_MID: int = 100
NAS_QUOTA_SENIOR: int = 200
NAS_QUOTA_MANAGER: int = 500
model_config = SettingsConfigDict(
# 不使用 pydantic-settings 的 env_file (避免網路磁碟I/O問題)
# 改用 python-dotenv 手動載入 (見檔案開頭)
case_sensitive=True,
)
# 全域配置實例
settings = Settings()

View File

@@ -0,0 +1,77 @@
"""簡化配置 - 用於測試"""
from pydantic_settings import BaseSettings
import os
# 直接從環境變數讀取,不依賴 pydantic-settings 的複雜功能
class Settings:
"""應用配置 (簡化版)"""
# 基本資訊
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "HR Portal API")
VERSION: str = os.getenv("VERSION", "2.0.0")
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
# 資料庫
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+psycopg://hr_admin:hr_dev_password_2026@localhost:5433/hr_portal")
DATABASE_ECHO: bool = os.getenv("DATABASE_ECHO", "False").lower() == "true"
# CORS
ALLOWED_ORIGINS: str = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://10.1.0.245:3000,https://hr.ease.taipei")
def get_allowed_origins(self):
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
# Keycloak
KEYCLOAK_URL: str = os.getenv("KEYCLOAK_URL", "https://auth.ease.taipei")
KEYCLOAK_REALM: str = os.getenv("KEYCLOAK_REALM", "porscheworld")
KEYCLOAK_CLIENT_ID: str = os.getenv("KEYCLOAK_CLIENT_ID", "hr-backend")
KEYCLOAK_CLIENT_SECRET: str = os.getenv("KEYCLOAK_CLIENT_SECRET", "")
KEYCLOAK_ADMIN_USERNAME: str = os.getenv("KEYCLOAK_ADMIN_USERNAME", "")
KEYCLOAK_ADMIN_PASSWORD: str = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "")
# JWT
JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
# 郵件
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "10.1.0.30")
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "True").lower() == "true"
MAIL_ADMIN_USER: str = os.getenv("MAIL_ADMIN_USER", "admin@porscheworld.tw")
MAIL_ADMIN_PASSWORD: str = os.getenv("MAIL_ADMIN_PASSWORD", "")
# NAS
NAS_HOST: str = os.getenv("NAS_HOST", "10.1.0.30")
NAS_PORT: int = int(os.getenv("NAS_PORT", "5000"))
NAS_USERNAME: str = os.getenv("NAS_USERNAME", "")
NAS_PASSWORD: str = os.getenv("NAS_PASSWORD", "")
NAS_WEBDAV_URL: str = os.getenv("NAS_WEBDAV_URL", "https://nas.lab.taipei/webdav")
NAS_SMB_SHARE: str = os.getenv("NAS_SMB_SHARE", "Working")
# 日誌
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
LOG_FILE: str = os.getenv("LOG_FILE", "logs/hr_portal.log")
# 分頁
DEFAULT_PAGE_SIZE: int = int(os.getenv("DEFAULT_PAGE_SIZE", "20"))
MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
# 郵件配額 (MB)
EMAIL_QUOTA_JUNIOR: int = int(os.getenv("EMAIL_QUOTA_JUNIOR", "1000"))
EMAIL_QUOTA_MID: int = int(os.getenv("EMAIL_QUOTA_MID", "2000"))
EMAIL_QUOTA_SENIOR: int = int(os.getenv("EMAIL_QUOTA_SENIOR", "5000"))
EMAIL_QUOTA_MANAGER: int = int(os.getenv("EMAIL_QUOTA_MANAGER", "10000"))
# NAS 配額 (GB)
NAS_QUOTA_JUNIOR: int = int(os.getenv("NAS_QUOTA_JUNIOR", "50"))
NAS_QUOTA_MID: int = int(os.getenv("NAS_QUOTA_MID", "100"))
NAS_QUOTA_SENIOR: int = int(os.getenv("NAS_QUOTA_SENIOR", "200"))
NAS_QUOTA_MANAGER: int = int(os.getenv("NAS_QUOTA_MANAGER", "500"))
# 載入 .env 並創建實例
from dotenv import load_dotenv
load_dotenv()
settings = Settings()

View File

@@ -0,0 +1,11 @@
"""測試配置"""
from pydantic_settings import BaseSettings
class TestSettings(BaseSettings):
PROJECT_NAME: str = "Test"
class Config:
env_file = ".env"
settings = TestSettings()
print(f"[OK] Settings loaded: {settings.PROJECT_NAME}")

View File

@@ -0,0 +1,54 @@
"""
日誌配置
"""
import logging
import sys
from pathlib import Path
from pythonjsonlogger import jsonlogger
def setup_logging():
"""設置日誌系統"""
# 延遲導入避免循環依賴
from app.core.config import settings
# 創建日誌目錄
log_file = Path(settings.LOG_FILE)
log_file.parent.mkdir(parents=True, exist_ok=True)
# 根日誌器
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, settings.LOG_LEVEL))
# 格式化器
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# JSON 格式化器 (生產環境)
json_formatter = jsonlogger.JsonFormatter(
"%(asctime)s %(name)s %(levelname)s %(message)s"
)
# 控制台處理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
# 文件處理器
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
if settings.ENVIRONMENT == "production":
file_handler.setFormatter(json_formatter)
else:
file_handler.setFormatter(formatter)
# 添加處理器
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# 設置第三方日誌級別
logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)

View File

10
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,10 @@
"""
資料庫 Base 類別
所有 SQLAlchemy Model 都繼承自此
"""
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
# NOTE: 不在這裡匯入 models,避免循環導入
# Models 的匯入應該在 alembic/env.py 中處理

50
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,50 @@
"""
資料庫連線管理 (延遲初始化版本)
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
# 延遲初始化
_engine = None
_SessionLocal = None
def get_engine():
"""獲取資料庫引擎 (延遲初始化)"""
global _engine
if _engine is None:
from app.core.config import settings
_engine = create_engine(
settings.DATABASE_URL,
echo=settings.DATABASE_ECHO,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
return _engine
def get_session_local():
"""獲取 Session 工廠 (延遲初始化)"""
global _SessionLocal
if _SessionLocal is None:
_SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=get_engine(),
)
return _SessionLocal
def get_db() -> Generator[Session, None, None]:
"""
取得資料庫 Session
用於 FastAPI 依賴注入
"""
SessionLocal = get_session_local()
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,52 @@
"""
資料庫連線管理
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
from app.core.config import settings
# 延遲初始化避免模組導入時就連接資料庫
_engine = None
_SessionLocal = None
def get_engine():
"""獲取資料庫引擎 (延遲初始化)"""
global _engine
if _engine is None:
_engine = create_engine(
settings.DATABASE_URL,
echo=settings.DATABASE_ECHO,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
return _engine
def get_session_local():
"""獲取 Session 工廠 (延遲初始化)"""
global _SessionLocal
if _SessionLocal is None:
_SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=get_engine(),
)
return _SessionLocal
# 向後兼容
engine = property(lambda self: get_engine())
SessionLocal = property(lambda self: get_session_local())
def get_db() -> Generator[Session, None, None]:
"""
取得資料庫 Session
用於 FastAPI 依賴注入
"""
db = get_session_local()()
try:
yield db
finally:
db.close()

105
backend/app/main.py Normal file
View File

@@ -0,0 +1,105 @@
"""
HR Portal Backend API
FastAPI 主應用程式
"""
import traceback
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.logging_config import setup_logging
from app.db.session import get_engine
from app.db.base import Base
# 設置日誌
setup_logging()
# 創建 FastAPI 應用
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
description="HR Portal - 人力資源管理系統 API",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
)
# CORS 設定
app.add_middleware(
CORSMiddleware,
allow_origins=settings.get_allowed_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 全局異常處理器
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全局異常處理器 - 記錄所有未捕獲的異常"""
print(f"\n{'=' * 80}")
print(f"[ERROR] Unhandled Exception in {request.method} {request.url.path}")
print(f"Exception Type: {type(exc).__name__}")
print(f"Exception Message: {str(exc)}")
print(f"Traceback:")
print(traceback.format_exc())
print(f"{'=' * 80}\n")
return JSONResponse(
status_code=500,
content={
"detail": str(exc),
"type": type(exc).__name__,
"path": request.url.path
}
)
# 啟動事件
@app.on_event("startup")
async def startup_event():
"""應用啟動時執行"""
# 資料庫表格由 Alembic 管理,不需要在啟動時創建
print(f"[OK] {settings.PROJECT_NAME} v{settings.VERSION} started!")
print(f"[*] Environment: {settings.ENVIRONMENT}")
print(f"[*] API Documentation: http://{settings.HOST}:{settings.PORT}/docs")
# 關閉事件
@app.on_event("shutdown")
async def shutdown_event():
"""應用關閉時執行"""
print(f"[*] {settings.PROJECT_NAME} stopped")
# 健康檢查端點
@app.get("/health", tags=["Health"])
async def health_check():
"""健康檢查"""
return JSONResponse(
content={
"status": "healthy",
"service": settings.PROJECT_NAME,
"version": settings.VERSION,
"environment": settings.ENVIRONMENT,
}
)
# 根路徑
@app.get("/", tags=["Root"])
async def root():
"""根路徑"""
return {
"message": f"Welcome to {settings.PROJECT_NAME}",
"version": settings.VERSION,
"docs": "/docs",
"redoc": "/redoc",
}
# 導入並註冊 API 路由
from app.api.v1.router import api_router
app.include_router(api_router, prefix="/api/v1")

View File

@@ -0,0 +1,84 @@
"""
Models 模組
匯出所有資料庫模型
"""
# 多租戶核心模型
from app.models.tenant import Tenant, TenantStatus
from app.models.system_function_cache import SystemFunctionCache
from app.models.system_function import SystemFunction
from app.models.personal_service import PersonalService
# HR 組織架構
from app.models.department import Department
from app.models.department_member import DepartmentMember
# HR 員工模型
from app.models.employee import Employee, EmployeeStatus
from app.models.emp_resume import EmpResume
from app.models.emp_setting import EmpSetting
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
# RBAC 權限系統
from app.models.role import UserRole, RoleRight, UserRoleAssignment
# 其他業務模型
from app.models.email_account import EmailAccount
from app.models.network_drive import NetworkDrive
from app.models.permission import Permission
from app.models.audit_log import AuditLog
from app.models.batch_log import BatchLog
# 初始化系統
from app.models.installation import (
InstallationSession,
InstallationChecklistItem,
InstallationChecklistResult,
InstallationStep,
InstallationLog,
InstallationTenantInfo,
InstallationDepartmentSetup,
TemporaryPassword,
InstallationAccessLog,
InstallationEnvironmentConfig,
InstallationSystemStatus
)
__all__ = [
# 多租戶核心
"Tenant",
"TenantStatus",
"SystemFunctionCache",
"SystemFunction",
"PersonalService",
# 組織架構
"Department",
"DepartmentMember",
# 員工模型
"Employee",
"EmployeeStatus",
"EmpResume",
"EmpSetting",
"EmpPersonalServiceSetting",
# RBAC 權限系統
"UserRole",
"RoleRight",
"UserRoleAssignment",
# 其他業務
"EmailAccount",
"NetworkDrive",
"Permission",
"AuditLog",
"BatchLog",
# 初始化系統
"InstallationSession",
"InstallationChecklistItem",
"InstallationChecklistResult",
"InstallationStep",
"InstallationLog",
"InstallationTenantInfo",
"InstallationDepartmentSetup",
"TemporaryPassword",
"InstallationAccessLog",
"InstallationEnvironmentConfig",
"InstallationSystemStatus",
]

View File

@@ -0,0 +1,64 @@
"""
審計日誌 Model
記錄所有關鍵操作,符合 ISO 要求
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Text, JSON, ForeignKey, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from app.db.base import Base
class AuditLog(Base):
"""審計日誌表"""
__tablename__ = "tenant_audit_logs"
__table_args__ = (
Index("idx_audit_tenant_action", "tenant_id", "action"),
Index("idx_audit_tenant_resource", "tenant_id", "resource_type", "resource_id"),
Index("idx_audit_tenant_time", "tenant_id", "performed_at"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
action = Column(String(50), nullable=False, index=True, comment="操作類型 (create/update/delete/login)")
resource_type = Column(String(50), nullable=False, index=True, comment="資源類型 (employee/department/role)")
resource_id = Column(Integer, nullable=True, index=True, comment="資源 ID")
performed_by = Column(String(100), nullable=False, index=True, comment="操作者 SSO 帳號")
performed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True, comment="操作時間")
details = Column(JSONB, nullable=True, comment="詳細變更內容 (JSON)")
ip_address = Column(String(45), nullable=True, comment="IP 位址 (IPv4/IPv6)")
# 通用欄位 (Note: audit_logs 不需要 is_active只記錄不修改)
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
tenant = relationship("Tenant")
def __repr__(self):
return f"<AuditLog {self.action} {self.resource_type}:{self.resource_id} by {self.performed_by}>"
@classmethod
def create_log(
cls,
tenant_id: int,
action: str,
resource_type: str,
performed_by: str,
resource_id: int = None,
details: dict = None,
ip_address: str = None,
) -> "AuditLog":
"""創建審計日誌"""
return cls(
tenant_id=tenant_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
performed_by=performed_by,
details=details,
ip_address=ip_address,
)

View File

@@ -0,0 +1,31 @@
"""
批次執行日誌 Model
記錄所有批次作業的執行結果
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from app.db.base import Base
class BatchLog(Base):
"""批次執行日誌表"""
__tablename__ = "tenant_batch_logs"
id = Column(Integer, primary_key=True, index=True)
batch_name = Column(String(100), nullable=False, index=True, comment="批次名稱")
status = Column(String(20), nullable=False, comment="執行狀態: success/failed/warning")
message = Column(Text, comment="執行訊息或錯誤詳情")
started_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True, comment="開始時間")
finished_at = Column(DateTime, comment="完成時間")
duration_seconds = Column(Integer, comment="執行時間 (秒)")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
def __repr__(self):
return f"<BatchLog {self.batch_name} [{self.status}] @ {self.started_at}>"

View File

@@ -0,0 +1,49 @@
"""
事業部 Model
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.db.base import Base
class BusinessUnit(Base):
"""事業部表"""
__tablename__ = "business_units"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_tenant_bu_code"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
name = Column(String(100), nullable=False, comment="事業部名稱")
name_en = Column(String(100), comment="英文名稱")
code = Column(String(20), nullable=False, index=True, comment="事業部代碼 (BD, TD, OM, 租戶內唯一)")
email_domain = Column(String(100), unique=True, nullable=False, comment="郵件網域 (ease.taipei, lab.taipei, porscheworld.tw)")
description = Column(Text, comment="說明")
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Phase 2.2 新增欄位
primary_domain = Column(String(100), comment="主要網域 (與 email_domain 相同)")
email_address = Column(String(255), comment="事業部信箱 (例如: business@ease.taipei)")
email_quota_mb = Column(Integer, default=10240, nullable=False, comment="事業部信箱配額 (MB)")
# 關聯
tenant = relationship("Tenant", back_populates="business_units")
# departments relationship 已移除 (business_unit_id FK 已從 departments 表刪除於 migration 0005)
employee_identities = relationship(
"EmployeeIdentity",
back_populates="business_unit",
lazy="dynamic"
)
def __repr__(self):
return f"<BusinessUnit {self.code} - {self.name}>"
@property
def sso_domain(self) -> str:
"""SSO 帳號網域"""
return self.email_domain

View File

@@ -0,0 +1,74 @@
"""
部門 Model
統一樹狀部門結構:
- depth=0, parent_id=NULL: 第一層部門 (原事業部),可設定 email_domain
- depth=1+: 子部門,繼承上層 email_domain
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class Department(Base):
"""部門表 (統一樹狀結構)"""
__tablename__ = "tenant_departments"
__table_args__ = (
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_dept_seq"),
UniqueConstraint("tenant_id", "parent_id", "code", name="uq_tenant_parent_dept_code"),
Index("idx_dept_tenant_id", "tenant_id"),
Index("idx_departments_parent", "parent_id"),
Index("idx_departments_depth", "depth"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
comment="租戶 ID")
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
parent_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=True,
comment="上層部門 ID (NULL=第一層,即原事業部)")
code = Column(String(20), nullable=False, comment="部門代碼 (同層內唯一)")
name = Column(String(100), nullable=False, comment="部門名稱")
name_en = Column(String(100), nullable=True, comment="英文名稱")
email_domain = Column(String(100), nullable=True,
comment="郵件網域 (只有 depth=0 可設定,例如 ease.taipei)")
email_address = Column(String(255), nullable=True, comment="部門信箱 (例如: wind@ease.taipei)")
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="部門信箱配額 (MB)")
depth = Column(Integer, default=0, nullable=False, comment="層次深度 (0=第一層1=第二層,以此類推)")
description = Column(Text, comment="說明")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
tenant = relationship("Tenant", back_populates="departments")
parent = relationship("Department", back_populates="children", remote_side="Department.id")
children = relationship("Department", back_populates="parent", cascade="all, delete-orphan")
members = relationship(
"DepartmentMember",
back_populates="department",
cascade="all, delete-orphan",
lazy="dynamic"
)
def __repr__(self):
return f"<Department depth={self.depth} code={self.code} name={self.name}>"
@property
def effective_email_domain(self) -> str | None:
"""有效郵件網域 (第一層自身設定,子層追溯上層)"""
if self.depth == 0:
return self.email_domain
if self.parent:
return self.parent.effective_email_domain
return None
@property
def is_top_level(self) -> bool:
"""是否為第一層部門 (原事業部)"""
return self.depth == 0 and self.parent_id is None

View File

@@ -0,0 +1,53 @@
"""
部門成員 Model
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class DepartmentMember(Base):
"""部門成員表"""
__tablename__ = "tenant_dept_members"
__table_args__ = (
UniqueConstraint("employee_id", "department_id", name="uq_employee_department"),
Index("idx_dept_members_tenant", "tenant_id"),
Index("idx_dept_members_employee", "employee_id"),
Index("idx_dept_members_department", "department_id"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
comment="租戶 ID")
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False,
comment="員工 ID")
department_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=False,
comment="部門 ID")
position = Column(String(100), nullable=True, comment="在該部門的職稱")
membership_type = Column(String(50), default="permanent", nullable=False,
comment="成員類型: permanent/temporary/project")
# 時間記錄(審計追蹤)
joined_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="加入時間")
ended_at = Column(DateTime, nullable=True, comment="離開時間(軟刪除)")
# 審計欄位
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
removed_by = Column(String(36), nullable=True, comment="移除者 keycloak_user_id")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
employee = relationship("Employee", back_populates="department_memberships")
department = relationship("Department", back_populates="members")
def __repr__(self):
return f"<DepartmentMember employee_id={self.employee_id} department_id={self.department_id}>"

View File

@@ -0,0 +1,123 @@
"""
郵件帳號 Model
支援員工在不同網域擁有多個郵件帳號,並管理配額
符合設計文件規範: HR Portal設計文件.md
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class EmailAccount(Base):
"""郵件帳號表 (一個員工可以有多個郵件帳號)"""
__tablename__ = "tenant_email_accounts"
__table_args__ = (
# 郵件地址必須唯一
Index("idx_email_accounts_email", "email_address", unique=True),
# 員工索引
Index("idx_email_accounts_employee", "employee_id"),
# 租戶索引
Index("idx_email_accounts_tenant", "tenant_id"),
# 狀態索引 (快速查詢啟用的帳號)
Index("idx_email_accounts_active", "is_active"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(
Integer,
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="租戶 ID"
)
# 支援個人/部門信箱
account_type = Column(
String(20),
default='personal',
nullable=False,
comment="帳號類型: personal(個人), department(部門)"
)
employee_id = Column(
Integer,
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
nullable=True, # 部門信箱不需要 employee_id
index=True,
comment="員工 ID (僅 personal 類型需要)"
)
department_id = Column(
Integer,
ForeignKey("tenant_departments.id", ondelete="CASCADE"),
nullable=True,
index=True,
comment="部門 ID (僅 department 類型需要)"
)
# 郵件設定
email_address = Column(
String(255),
unique=True,
nullable=False,
index=True,
comment="郵件地址 (例如: porsche.chen@lab.taipei)"
)
quota_mb = Column(
Integer,
default=2048,
nullable=False,
comment="配額 (MB),依職級: Junior=2048, Mid=3072, Senior=5120, Manager=10240"
)
# 進階功能
forward_to = Column(
String(255),
nullable=True,
comment="轉寄地址 (可選,例如外部郵箱)"
)
auto_reply = Column(
Text,
nullable=True,
comment="自動回覆內容 (可選,例如休假通知)"
)
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 關聯
employee = relationship("Employee", back_populates="email_accounts")
tenant = relationship("Tenant")
def __repr__(self):
return f"<EmailAccount {self.email_address} (配額:{self.quota_mb}MB)>"
@property
def local_part(self) -> str:
"""郵件前綴 (@ 之前的部分)"""
return self.email_address.split('@')[0] if '@' in self.email_address else self.email_address
@property
def domain_part(self) -> str:
"""網域部分 (@ 之後的部分)"""
return self.email_address.split('@')[1] if '@' in self.email_address else ""
@property
def quota_gb(self) -> float:
"""配額 (GB,用於顯示)"""
return round(self.quota_mb / 1024, 2)
@classmethod
def get_default_quota_by_level(cls, job_level: str) -> int:
"""根據職級取得預設配額 (MB)"""
quota_map = {
"Junior": 2048,
"Mid": 3072,
"Senior": 5120,
"Manager": 10240,
}
return quota_map.get(job_level, 2048)

View File

@@ -0,0 +1,50 @@
"""
員工個人化服務設定 Model
記錄員工啟用的個人化服務SSO, Email, Calendar, Drive, Office
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class EmpPersonalServiceSetting(Base):
"""員工個人化服務設定表"""
__tablename__ = "tenant_emp_personal_service_settings"
__table_args__ = (
UniqueConstraint("tenant_id", "keycloak_user_id", "service_id", name="uq_emp_service"),
Index("idx_emp_service_tenant", "tenant_id"),
Index("idx_emp_service_user", "keycloak_user_id"),
Index("idx_emp_service_service", "service_id"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
comment="租戶 ID")
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID")
service_id = Column(Integer, ForeignKey("personal_services.id", ondelete="CASCADE"), nullable=False,
comment="個人化服務 ID")
# 服務配額設定(依服務類型不同)
quota_gb = Column(Integer, nullable=True, comment="儲存配額 (GB),適用於 Drive")
quota_mb = Column(Integer, nullable=True, comment="郵件配額 (MB),適用於 Email")
# 審計欄位(完整記錄)
enabled_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="啟用時間")
enabled_by = Column(String(36), nullable=True, comment="啟用者 keycloak_user_id")
disabled_at = Column(DateTime, nullable=True, comment="停用時間(軟刪除)")
disabled_by = Column(String(36), nullable=True, comment="停用者 keycloak_user_id")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
service = relationship("PersonalService")
def __repr__(self):
return f"<EmpPersonalServiceSetting user={self.keycloak_user_id} service={self.service_id}>"

View File

@@ -0,0 +1,69 @@
"""
員工履歷資料 Model (人員基本檔)
記錄員工的個人資料、教育背景等(與任用無關的基本資料)
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class EmpResume(Base):
"""員工履歷表(人員基本檔)"""
__tablename__ = "tenant_emp_resumes"
__table_args__ = (
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_resume_seq"),
UniqueConstraint("tenant_id", "id_number", name="uq_tenant_id_number"),
Index("idx_emp_resume_tenant", "tenant_id"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
comment="租戶 ID")
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
# 個人基本資料
legal_name = Column(String(100), nullable=False, comment="法定姓名")
english_name = Column(String(100), nullable=True, comment="英文名稱")
id_number = Column(String(20), nullable=False, comment="身分證字號/護照號碼")
birth_date = Column(Date, nullable=True, comment="出生日期")
gender = Column(String(10), nullable=True, comment="性別: M/F/Other")
marital_status = Column(String(20), nullable=True, comment="婚姻狀況: single/married/divorced/widowed")
nationality = Column(String(50), nullable=True, comment="國籍")
# 聯絡資訊
phone = Column(String(20), nullable=True, comment="聯絡電話")
mobile = Column(String(20), nullable=True, comment="手機")
personal_email = Column(String(255), nullable=True, comment="個人郵箱")
address = Column(Text, nullable=True, comment="通訊地址")
emergency_contact = Column(String(100), nullable=True, comment="緊急聯絡人")
emergency_phone = Column(String(20), nullable=True, comment="緊急聯絡電話")
# 教育背景
education_level = Column(String(50), nullable=True, comment="學歷: high_school/bachelor/master/phd")
school_name = Column(String(200), nullable=True, comment="畢業學校")
major = Column(String(100), nullable=True, comment="主修科系")
graduation_year = Column(Integer, nullable=True, comment="畢業年份")
# 備註
notes = Column(Text, nullable=True, comment="備註")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
tenant = relationship("Tenant")
employment_setting = relationship(
"EmpSetting",
back_populates="resume",
uselist=False,
cascade="all, delete-orphan"
)
def __repr__(self):
return f"<EmpResume {self.legal_name} ({self.id_number})>"

View File

@@ -0,0 +1,86 @@
"""
員工任用設定 Model (員工任用資料檔)
記錄員工的任用資訊、職務、薪資等(與組織任用相關的資料)
使用複合主鍵 (tenant_id, seq_no)
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class EmpSetting(Base):
"""員工任用設定表(複合主鍵)"""
__tablename__ = "tenant_emp_settings"
__table_args__ = (
UniqueConstraint("tenant_id", "tenant_resume_id", name="uq_tenant_resume_setting"),
UniqueConstraint("tenant_id", "tenant_emp_code", name="uq_tenant_emp_code"),
Index("idx_emp_setting_tenant", "tenant_id"),
)
# 複合主鍵
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True,
comment="租戶 ID")
seq_no = Column(Integer, primary_key=True, comment="租戶內序號 (觸發器自動生成)")
# 關聯人員基本檔
tenant_resume_id = Column(Integer, ForeignKey("tenant_emp_resumes.id", ondelete="RESTRICT"), nullable=False,
comment="人員基本檔 ID一個人只有一筆任用設定")
# 員工編號(自動生成)
tenant_emp_code = Column(String(20), nullable=False, index=True,
comment="員工編號(自動生成,格式: prefix + seq_no例如 PWD0001")
# SSO 整合
tenant_keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變)")
tenant_keycloak_username = Column(String(100), unique=True, nullable=True,
comment="Keycloak 登入帳號")
# 任用資訊
hire_at = Column(Date, nullable=False, comment="到職日期")
resign_date = Column(Date, nullable=True, comment="離職日期")
job_title = Column(String(100), nullable=True, comment="職稱")
employment_type = Column(String(50), nullable=False, default="full_time",
comment="任用類型: full_time/part_time/contractor/intern")
# 薪資資訊(加密儲存)
salary_amount = Column(Integer, nullable=True, comment="月薪(加密)")
salary_currency = Column(String(10), default="TWD", comment="薪資幣別")
# 主要部門(員工可屬於多個部門,但有一個主要部門)
primary_dept_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="SET NULL"), nullable=True,
comment="主要部門 ID")
# 個人化服務配額設定
storage_quota_gb = Column(Integer, default=20, nullable=False, comment="儲存配額 (GB) - Drive 使用")
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="郵件配額 (MB) - Email 使用")
# 狀態
employment_status = Column(String(20), default="active", nullable=False,
comment="任用狀態: active/on_leave/resigned/terminated")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
tenant = relationship("Tenant")
resume = relationship("EmpResume", back_populates="employment_setting")
primary_department = relationship("Department", foreign_keys=[primary_dept_id])
# 關聯:部門歸屬(多對多)- 透過 resume 的 employee 關聯
# department_memberships 在 Employee Model 中定義
# 關聯:角色分配(多對多)- 透過 keycloak_user_id 查詢
# user_role_assignments 在 UserRoleAssignment Model 中定義
# 關聯:個人化服務設定(多對多)- 透過 keycloak_user_id 查詢
# personal_service_settings 在 EmpPersonalServiceSetting Model 中定義
def __repr__(self):
return f"<EmpSetting {self.tenant_emp_code} (tenant_id={self.tenant_id}, seq_no={self.seq_no})>"

View File

@@ -0,0 +1,85 @@
"""
員工基本資料 Model
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Enum, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
import enum
from app.db.base import Base
class EmployeeStatus(str, enum.Enum):
"""員工狀態"""
ACTIVE = "active"
INACTIVE = "inactive"
TERMINATED = "terminated"
class Employee(Base):
"""員工基本資料表"""
__tablename__ = "tenant_employees"
__table_args__ = (
UniqueConstraint("tenant_id", "employee_id", name="uq_tenant_employee_id"),
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_seq_no"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (自動從1開始)")
employee_id = Column(String(20), nullable=False, index=True, comment="員工編號 (EMP001, 租戶內唯一,永久不變)")
keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變,一個員工只有一個)")
username_base = Column(String(50), unique=True, nullable=False, index=True, comment="基礎帳號名稱 (全系統唯一)")
legal_name = Column(String(100), nullable=False, comment="法定姓名")
english_name = Column(String(100), comment="英文名稱")
phone = Column(String(20), comment="電話")
mobile = Column(String(20), comment="手機")
hire_date = Column(Date, nullable=False, comment="到職日期")
status = Column(
String(20),
default=EmployeeStatus.ACTIVE,
nullable=False,
comment="狀態 (active/inactive/terminated)"
)
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
tenant = relationship("Tenant", back_populates="employees")
department_memberships = relationship(
"DepartmentMember",
back_populates="employee",
cascade="all, delete-orphan",
lazy="selectin"
)
email_accounts = relationship(
"EmailAccount",
back_populates="employee",
cascade="all, delete-orphan",
lazy="selectin"
)
permissions = relationship(
"Permission",
foreign_keys="Permission.employee_id",
back_populates="employee",
cascade="all, delete-orphan",
lazy="selectin"
)
network_drive = relationship(
"NetworkDrive",
back_populates="employee",
uselist=False,
cascade="all, delete-orphan",
lazy="selectin"
)
def __repr__(self):
return f"<Employee {self.employee_id} - {self.legal_name}>"
# is_active 已改為資料庫欄位,移除 @property

View File

@@ -0,0 +1,66 @@
"""
員工身份 Model
一個員工可以在多個事業部任職,每個事業部對應一個身份
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.db.base import Base
class EmployeeIdentity(Base):
"""員工身份表"""
__tablename__ = "employee_identities"
__table_args__ = (
UniqueConstraint("tenant_id", "employee_id", "business_unit_id", name="uq_tenant_emp_bu"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
# SSO 帳號 (= 郵件地址)
username = Column(String(100), unique=True, nullable=False, index=True, comment="SSO 帳號 (porsche.chen@lab.taipei)")
keycloak_id = Column(String(100), unique=True, nullable=False, index=True, comment="Keycloak UUID")
# 組織與職務
business_unit_id = Column(Integer, ForeignKey("business_units.id"), nullable=False, index=True)
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True, index=True)
job_title = Column(String(100), nullable=False, comment="職稱")
job_level = Column(String(20), nullable=False, comment="職級 (Junior/Mid/Senior/Manager)")
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要身份")
# 郵件配額
email_quota_mb = Column(Integer, nullable=False, comment="郵件配額 (MB)")
# 時間記錄
started_at = Column(Date, nullable=False, comment="開始日期")
ended_at = Column(Date, nullable=True, comment="結束日期")
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 關聯
tenant = relationship("Tenant")
employee = relationship("Employee", back_populates="identities")
business_unit = relationship("BusinessUnit", back_populates="employee_identities")
department = relationship("Department") # back_populates 已移除 (employee_identities 廢棄)
def __repr__(self):
return f"<EmployeeIdentity {self.username}>"
@property
def email(self) -> str:
"""郵件地址 (= SSO 帳號)"""
return self.username
@property
def is_cross_department(self) -> bool:
"""是否跨部門任職 (檢查同一員工是否有其他身份)"""
return len(self.employee.identities) > 1
def generate_username(self, username_base: str, email_domain: str) -> str:
"""生成 SSO 帳號"""
return f"{username_base}@{email_domain}"

View File

@@ -0,0 +1,362 @@
"""
Installation System Models
初始化系統資料模型
"""
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Boolean, Text, TIMESTAMP, ForeignKey, ARRAY
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from app.db.base import Base
class InstallationSession(Base):
"""安裝會話"""
__tablename__ = "installation_sessions"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_name = Column(String(200))
environment = Column(String(20)) # development/testing/production
# 狀態追蹤
started_at = Column(TIMESTAMP, default=datetime.now)
completed_at = Column(TIMESTAMP)
status = Column(String(20), default='in_progress') # in_progress/completed/failed/paused
# 進度統計
total_checklist_items = Column(Integer)
passed_checklist_items = Column(Integer, default=0)
failed_checklist_items = Column(Integer, default=0)
total_steps = Column(Integer)
completed_steps = Column(Integer, default=0)
failed_steps = Column(Integer, default=0)
executed_by = Column(String(100))
# 存取控制
is_locked = Column(Boolean, default=False)
locked_at = Column(TIMESTAMP)
locked_by = Column(String(100))
lock_reason = Column(String(200))
is_unlocked = Column(Boolean, default=False)
unlocked_at = Column(TIMESTAMP)
unlocked_by = Column(String(100))
unlock_reason = Column(String(200))
unlock_expires_at = Column(TIMESTAMP)
last_viewed_at = Column(TIMESTAMP)
last_viewed_by = Column(String(100))
view_count = Column(Integer, default=0)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
tenant_info = relationship("InstallationTenantInfo", back_populates="session", uselist=False)
department_setups = relationship("InstallationDepartmentSetup", back_populates="session")
temporary_passwords = relationship("TemporaryPassword", back_populates="session")
access_logs = relationship("InstallationAccessLog", back_populates="session")
checklist_results = relationship("InstallationChecklistResult", back_populates="session")
installation_logs = relationship("InstallationLog", back_populates="session")
class InstallationChecklistItem(Base):
"""檢查項目定義(系統級)"""
__tablename__ = "installation_checklist_items"
id = Column(Integer, primary_key=True, index=True)
category = Column(String(50), nullable=False) # hardware/network/software/container/security
item_code = Column(String(100), unique=True, nullable=False)
item_name = Column(String(200), nullable=False)
check_type = Column(String(50), nullable=False) # command/api/config/manual
check_command = Column(Text) # 自動檢查命令
expected_value = Column(Text)
min_requirement = Column(Text)
recommended_value = Column(Text)
is_required = Column(Boolean, default=True)
sequence_order = Column(Integer, nullable=False)
description = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
results = relationship("InstallationChecklistResult", back_populates="checklist_item")
class InstallationChecklistResult(Base):
"""檢查結果(租戶級)"""
__tablename__ = "installation_checklist_results"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
checklist_item_id = Column(Integer, ForeignKey("installation_checklist_items.id", ondelete="CASCADE"), nullable=False)
status = Column(String(20), nullable=False) # pass/fail/warning/pending/skip
actual_value = Column(Text)
checked_at = Column(TIMESTAMP)
checked_by = Column(String(100))
auto_checked = Column(Boolean, default=False)
remarks = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="checklist_results")
checklist_item = relationship("InstallationChecklistItem", back_populates="results")
class InstallationStep(Base):
"""安裝步驟定義(系統級)"""
__tablename__ = "installation_steps"
id = Column(Integer, primary_key=True, index=True)
step_code = Column(String(50), unique=True, nullable=False)
step_name = Column(String(200), nullable=False)
phase = Column(String(20), nullable=False) # phase1/phase2/...
sequence_order = Column(Integer, nullable=False)
description = Column(Text)
execution_type = Column(String(50)) # auto/manual/script
execution_script = Column(Text)
depends_on_steps = Column(ARRAY(String)) # 依賴的步驟代碼
is_required = Column(Boolean, default=True)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
logs = relationship("InstallationLog", back_populates="step")
class InstallationLog(Base):
"""安裝執行記錄(租戶級)"""
__tablename__ = "installation_logs"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
step_id = Column(Integer, ForeignKey("installation_steps.id", ondelete="CASCADE"), nullable=False)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
status = Column(String(20), nullable=False) # pending/running/success/failed/skipped
started_at = Column(TIMESTAMP)
completed_at = Column(TIMESTAMP)
executed_by = Column(String(100))
execution_method = Column(String(50)) # manual/auto/api/script
result_data = Column(JSONB)
error_message = Column(Text)
retry_count = Column(Integer, default=0)
remarks = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
step = relationship("InstallationStep", back_populates="logs")
session = relationship("InstallationSession", back_populates="installation_logs")
class InstallationTenantInfo(Base):
"""租戶初始化資訊"""
__tablename__ = "installation_tenant_info"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True, unique=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
# 公司基本資訊
company_name = Column(String(200))
company_name_en = Column(String(200))
tenant_code = Column(String(50)) # 租戶代碼 = Keycloak Realm
tenant_prefix = Column(String(10)) # 員工編號前綴
tax_id = Column(String(50))
industry = Column(String(100))
company_size = Column(String(20)) # small/medium/large
# 聯絡資訊
tel = Column(String(20)) # 公司電話(對應 tenants.tel
phone = Column(String(50))
fax = Column(String(50))
email = Column(String(200))
website = Column(String(200))
add = Column(Text) # 公司地址(對應 tenants.add
address = Column(Text)
address_en = Column(Text)
# 郵件網域設定
domain_set = Column(Integer, default=2) # 1=組織網域, 2=部門網域
domain = Column(String(100)) # 組織網域domain_set=1 時使用)
# 負責人資訊
representative_name = Column(String(100))
representative_title = Column(String(100))
representative_email = Column(String(200))
representative_phone = Column(String(50))
# 系統管理員資訊
admin_employee_id = Column(String(50))
admin_username = Column(String(100))
admin_legal_name = Column(String(100))
admin_english_name = Column(String(100))
admin_email = Column(String(200))
admin_phone = Column(String(50))
# 初始設定
default_language = Column(String(10), default='zh-TW')
timezone = Column(String(50), default='Asia/Taipei')
date_format = Column(String(20), default='YYYY-MM-DD')
currency = Column(String(10), default='TWD')
# 狀態追蹤
is_completed = Column(Boolean, default=False)
completed_at = Column(TIMESTAMP)
completed_by = Column(String(100))
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="tenant_info")
class InstallationDepartmentSetup(Base):
"""部門架構設定"""
__tablename__ = "installation_department_setup"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
department_code = Column(String(50), nullable=False)
department_name = Column(String(200), nullable=False)
department_name_en = Column(String(200))
email_domain = Column(String(100))
parent_code = Column(String(50))
depth = Column(Integer, default=0)
manager_name = Column(String(100))
is_created = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
# 不定義 tenant relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="department_setups")
class TemporaryPassword(Base):
"""臨時密碼"""
__tablename__ = "temporary_passwords"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
employee_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 employees 表可能不存在
username = Column(String(100), nullable=False)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
# 密碼資訊
password_hash = Column(String(255), nullable=False)
plain_password = Column(String(100)) # 明文密碼(僅初始化階段)
password_method = Column(String(20)) # auto/manual
is_temporary = Column(Boolean, default=True)
must_change_on_login = Column(Boolean, default=True)
# 有效期限
created_at = Column(TIMESTAMP, default=datetime.now)
expires_at = Column(TIMESTAMP)
# 使用狀態
is_used = Column(Boolean, default=False)
used_at = Column(TIMESTAMP)
first_login_at = Column(TIMESTAMP)
password_changed_at = Column(TIMESTAMP)
# 查看控制
is_viewable = Column(Boolean, default=True)
viewable_until = Column(TIMESTAMP)
view_count = Column(Integer, default=0)
last_viewed_at = Column(TIMESTAMP)
first_viewed_at = Column(TIMESTAMP)
# 明文密碼清除記錄
plain_password_cleared_at = Column(TIMESTAMP)
cleared_reason = Column(String(100))
# Relationships
# 不定義 tenant 和 employee relationship因為沒有 FK constraint
session = relationship("InstallationSession", back_populates="temporary_passwords")
class InstallationAccessLog(Base):
"""存取審計日誌"""
__tablename__ = "installation_access_logs"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"), nullable=False)
action = Column(String(50), nullable=False) # lock/unlock/view/download_pdf
action_by = Column(String(100))
action_method = Column(String(50)) # database/api/system
ip_address = Column(String(50))
user_agent = Column(Text)
access_granted = Column(Boolean)
deny_reason = Column(String(200))
sensitive_data_accessed = Column(ARRAY(String))
created_at = Column(TIMESTAMP, default=datetime.now)
# Relationships
session = relationship("InstallationSession", back_populates="access_logs")
class InstallationEnvironmentConfig(Base):
"""環境配置記錄"""
__tablename__ = "installation_environment_config"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
config_key = Column(String(100), unique=True, nullable=False, index=True)
config_value = Column(Text)
config_category = Column(String(50), nullable=False, index=True) # redis/database/keycloak/mailserver/nextcloud/traefik
is_sensitive = Column(Boolean, default=False) # 是否為敏感資訊(密碼等)
is_configured = Column(Boolean, default=False)
configured_at = Column(TIMESTAMP)
configured_by = Column(String(100))
description = Column(Text)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
# Relationships
session = relationship("InstallationSession")
class InstallationSystemStatus(Base):
"""系統狀態記錄三階段Initialization/Operational/Transition"""
__tablename__ = "installation_system_status"
id = Column(Integer, primary_key=True, index=True)
current_phase = Column(String(20), nullable=False, index=True) # initialization/operational/transition
previous_phase = Column(String(20))
phase_changed_at = Column(TIMESTAMP)
phase_changed_by = Column(String(100))
phase_change_reason = Column(Text)
# Initialization 階段資訊
initialized_at = Column(TIMESTAMP)
initialized_by = Column(String(100))
initialization_completed = Column(Boolean, default=False)
# Operational 階段資訊
last_health_check_at = Column(TIMESTAMP)
health_check_status = Column(String(20)) # healthy/degraded/unhealthy
operational_since = Column(TIMESTAMP)
# Transition 階段資訊
transition_started_at = Column(TIMESTAMP)
transition_approved_by = Column(String(100))
env_db_consistent = Column(Boolean)
consistency_checked_at = Column(TIMESTAMP)
inconsistencies = Column(Text) # JSON 格式
# 系統鎖定
is_locked = Column(Boolean, default=False)
locked_at = Column(TIMESTAMP)
locked_by = Column(String(100))
lock_reason = Column(String(200))
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -0,0 +1,107 @@
"""
發票記錄 Model
管理租戶的帳單和發票
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
import enum
from app.db.base import Base
class InvoiceStatus(str, enum.Enum):
"""發票狀態"""
PENDING = "pending" # 待付款
PAID = "paid" # 已付款
OVERDUE = "overdue" # 逾期未付
CANCELLED = "cancelled" # 已取消
class Invoice(Base):
"""發票記錄表"""
__tablename__ = "invoices"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
# 發票資訊
invoice_number = Column(String(50), unique=True, nullable=False, index=True, comment="發票號碼 (INV-2026-03-001)")
issue_date = Column(Date, nullable=False, comment="開立日期")
due_date = Column(Date, nullable=False, comment="到期日")
# 金額
amount = Column(Numeric(10, 2), nullable=False, comment="金額 (未稅)")
tax = Column(Numeric(10, 2), default=0, nullable=False, comment="稅額")
total = Column(Numeric(10, 2), nullable=False, comment="總計 (含稅)")
# 狀態
status = Column(String(20), default=InvoiceStatus.PENDING, nullable=False, comment="狀態")
# 付款資訊
paid_at = Column(DateTime, nullable=True, comment="付款時間")
payment_method = Column(String(20), nullable=True, comment="付款方式 (credit_card/wire_transfer)")
# 發票明細 (JSON 格式)
line_items = Column(JSONB, nullable=True, comment="發票明細")
# 範例:
# [
# {"description": "標準方案 (20 人)", "quantity": 1, "unit_price": 10000, "amount": 10000},
# {"description": "超額用戶 (2 人)", "quantity": 2, "unit_price": 500, "amount": 1000}
# ]
# PDF 檔案
pdf_path = Column(String(200), nullable=True, comment="發票 PDF 路徑")
# 備註
notes = Column(Text, nullable=True, comment="備註")
# 時間記錄
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 關聯
tenant = relationship("Tenant")
payments = relationship(
"Payment",
back_populates="invoice",
cascade="all, delete-orphan",
lazy="selectin"
)
def __repr__(self):
return f"<Invoice {self.invoice_number} - NT$ {self.total} ({self.status})>"
@property
def is_paid(self) -> bool:
"""是否已付款"""
return self.status == InvoiceStatus.PAID
@property
def is_overdue(self) -> bool:
"""是否逾期"""
return (
self.status in [InvoiceStatus.PENDING, InvoiceStatus.OVERDUE] and
date.today() > self.due_date
)
@property
def days_overdue(self) -> int:
"""逾期天數"""
if not self.is_overdue:
return 0
return (date.today() - self.due_date).days
def mark_as_paid(self, payment_method: str = None):
"""標記為已付款"""
self.status = InvoiceStatus.PAID
self.paid_at = datetime.utcnow()
if payment_method:
self.payment_method = payment_method
@classmethod
def generate_invoice_number(cls, year: int, month: int, sequence: int) -> str:
"""生成發票號碼"""
return f"INV-{year:04d}-{month:02d}-{sequence:03d}"

View File

@@ -0,0 +1,68 @@
"""
網路硬碟 Model
一個員工對應一個 NAS 帳號
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.db.base import Base
class NetworkDrive(Base):
"""網路硬碟表"""
__tablename__ = "tenant_network_drives"
__table_args__ = (
UniqueConstraint("employee_id", name="uq_network_drive_employee"),
)
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
# 一個員工只有一個 NAS 帳號
drive_name = Column(String(100), unique=True, nullable=False, comment="NAS 帳號名稱 (與 username_base 相同)")
quota_gb = Column(Integer, nullable=False, comment="配額 (GB),取所有身份中的最高職級")
# 訪問路徑
webdav_url = Column(String(255), comment="WebDAV 路徑")
smb_url = Column(String(255), comment="SMB 路徑")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
employee = relationship("Employee", back_populates="network_drive")
def __repr__(self):
return f"<NetworkDrive {self.drive_name} - {self.quota_gb}GB>"
@property
def webdav_path(self) -> str:
"""WebDAV 完整路徑"""
return self.webdav_url or f"https://nas.lab.taipei/webdav/{self.drive_name}"
@property
def smb_path(self) -> str:
"""SMB 完整路徑"""
return self.smb_url or f"\\\\10.1.0.30\\{self.drive_name}"
def update_quota_from_job_level(self, job_level: str) -> None:
"""根據職級更新配額"""
from app.core.config import settings
quota_mapping = {
"Junior": settings.NAS_QUOTA_JUNIOR,
"Mid": settings.NAS_QUOTA_MID,
"Senior": settings.NAS_QUOTA_SENIOR,
"Manager": settings.NAS_QUOTA_MANAGER,
}
new_quota = quota_mapping.get(job_level, settings.NAS_QUOTA_JUNIOR)
# 只在配額增加時更新 (不降低配額)
if new_quota > self.quota_gb:
self.quota_gb = new_quota

View File

@@ -0,0 +1,51 @@
"""
付款記錄 Model
記錄所有付款交易
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Numeric, ForeignKey, Text
from sqlalchemy.orm import relationship
import enum
from app.db.base import Base
class PaymentStatus(str, enum.Enum):
"""付款狀態"""
SUCCESS = "success" # 成功
FAILED = "failed" # 失敗
PENDING = "pending" # 處理中
REFUNDED = "refunded" # 已退款
class Payment(Base):
"""付款記錄表"""
__tablename__ = "payments"
id = Column(Integer, primary_key=True, index=True)
invoice_id = Column(Integer, ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
# 付款資訊
amount = Column(Numeric(10, 2), nullable=False, comment="付款金額")
payment_method = Column(String(20), nullable=False, comment="付款方式 (credit_card/wire_transfer/cash)")
transaction_id = Column(String(100), nullable=True, comment="金流交易編號")
status = Column(String(20), default=PaymentStatus.PENDING, nullable=False, comment="狀態")
# 時間記錄
paid_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="付款時間")
# 備註
notes = Column(Text, nullable=True, comment="備註")
# 關聯
invoice = relationship("Invoice", back_populates="payments")
def __repr__(self):
return f"<Payment NT$ {self.amount} - {self.status}>"
@property
def is_success(self) -> bool:
"""是否付款成功"""
return self.status == PaymentStatus.SUCCESS

View File

@@ -0,0 +1,112 @@
"""
系統權限 Model
管理員工在各系統的存取權限 (Gitea, Portainer, etc.)
符合設計文件規範: HR Portal設計文件.md
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy.orm import relationship
from app.db.base import Base
class Permission(Base):
"""系統權限表"""
__tablename__ = "tenant_permissions"
__table_args__ = (
# 同一員工在同一系統只能有一個權限記錄
UniqueConstraint("employee_id", "system_name", name="uq_employee_system"),
# 索引
Index("idx_permissions_employee", "employee_id"),
Index("idx_permissions_tenant", "tenant_id"),
Index("idx_permissions_system", "system_name"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(
Integer,
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="租戶 ID"
)
employee_id = Column(
Integer,
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="員工 ID"
)
# 權限設定
system_name = Column(
String(100),
nullable=False,
index=True,
comment="系統名稱 (gitea, portainer, traefik, keycloak)"
)
access_level = Column(
String(50),
default='user',
nullable=False,
comment="存取層級 (admin/user/readonly)"
)
# 授予資訊
granted_at = Column(
DateTime,
default=datetime.utcnow,
nullable=False,
comment="授予時間"
)
granted_by = Column(
Integer,
ForeignKey("tenant_employees.id", ondelete="SET NULL"),
nullable=True,
comment="授予人 (員工 ID)"
)
# 通用欄位 (Note: Permission 表不需要 is_active依靠 granted_at 判斷)
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
employee = relationship(
"Employee",
foreign_keys=[employee_id],
back_populates="permissions"
)
granted_by_employee = relationship(
"Employee",
foreign_keys=[granted_by]
)
granter = relationship(
"Employee",
foreign_keys=[granted_by],
viewonly=True,
)
tenant = relationship("Tenant")
def __repr__(self):
return f"<Permission {self.system_name}:{self.access_level}>"
@classmethod
def get_available_systems(cls) -> list[str]:
"""取得可用的系統清單"""
return [
"gitea", # Git 代碼託管
"portainer", # 容器管理
"traefik", # 反向代理管理
"keycloak", # SSO 管理
]
@classmethod
def get_available_access_levels(cls) -> list[str]:
"""取得可用的存取層級"""
return [
"admin", # 管理員 (完整控制)
"user", # 一般使用者
"readonly", # 唯讀
]

View File

@@ -0,0 +1,31 @@
"""
個人化服務 Model
定義可為員工啟用的個人服務SSO、Email、Calendar、Drive、Office
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint
from app.db.base import Base
class PersonalService(Base):
"""個人化服務表"""
__tablename__ = "personal_services"
__table_args__ = (
UniqueConstraint("service_code", name="uq_personal_service_code"),
)
id = Column(Integer, primary_key=True, index=True)
service_code = Column(String(20), unique=True, nullable=False, comment="服務代碼: SSO/Email/Calendar/Drive/Office")
service_name = Column(String(100), nullable=False, comment="服務名稱")
description = Column(String(500), nullable=True, comment="服務說明")
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
# 通用欄位
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
def __repr__(self):
return f"<PersonalService {self.service_code} - {self.service_name}>"

120
backend/app/models/role.py Normal file
View File

@@ -0,0 +1,120 @@
"""
RBAC 角色相關 Models
- UserRole: 租戶層級角色 (不綁定部門)
- RoleRight: 角色對系統功能的 CRUD 權限
- UserRoleAssignment: 使用者角色分配 (直接對人,跨部門有效)
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class UserRole(Base):
"""角色表 (租戶層級,不綁定部門)"""
__tablename__ = "tenant_user_roles"
__table_args__ = (
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_role_seq"),
UniqueConstraint("tenant_id", "role_code", name="uq_tenant_role_code"),
Index("idx_roles_tenant", "tenant_id"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
comment="租戶 ID")
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
role_code = Column(String(100), nullable=False, comment="角色代碼 (租戶內唯一,例如 HR_ADMIN)")
role_name = Column(String(200), nullable=False, comment="角色名稱")
description = Column(Text, nullable=True, comment="角色說明")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
tenant = relationship("Tenant", back_populates="user_roles")
rights = relationship("RoleRight", back_populates="role", cascade="all, delete-orphan", lazy="selectin")
user_assignments = relationship("UserRoleAssignment", back_populates="role", cascade="all, delete-orphan",
lazy="dynamic")
def __repr__(self):
return f"<UserRole {self.role_code} - {self.role_name}>"
class RoleRight(Base):
"""角色功能權限表 (Role and System Right)"""
__tablename__ = "tenant_role_rights"
__table_args__ = (
UniqueConstraint("role_id", "function_id", name="uq_role_function"),
Index("idx_role_rights_role", "role_id"),
Index("idx_role_rights_function", "function_id"),
)
id = Column(Integer, primary_key=True, index=True)
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
comment="角色 ID")
function_id = Column(Integer, ForeignKey("system_functions_cache.id", ondelete="CASCADE"), nullable=False,
comment="系統功能 ID")
can_read = Column(Boolean, default=False, nullable=False, comment="查詢權限")
can_create = Column(Boolean, default=False, nullable=False, comment="新增權限")
can_update = Column(Boolean, default=False, nullable=False, comment="修改權限")
can_delete = Column(Boolean, default=False, nullable=False, comment="刪除權限")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
role = relationship("UserRole", back_populates="rights")
function = relationship("SystemFunctionCache")
def __repr__(self):
perms = []
if self.can_read: perms.append("R")
if self.can_create: perms.append("C")
if self.can_update: perms.append("U")
if self.can_delete: perms.append("D")
return f"<RoleRight role={self.role_id} fn={self.function_id} [{','.join(perms)}]>"
class UserRoleAssignment(Base):
"""使用者角色分配表 (直接對人,跨部門有效)"""
__tablename__ = "tenant_user_role_assignments"
__table_args__ = (
UniqueConstraint("keycloak_user_id", "role_id", name="uq_user_role"),
Index("idx_user_roles_tenant", "tenant_id"),
Index("idx_user_roles_keycloak", "keycloak_user_id"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
comment="租戶 ID")
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID (永久識別碼)")
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
comment="角色 ID")
# 審計欄位(完整記錄)
assigned_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="分配時間")
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
revoked_at = Column(DateTime, nullable=True, comment="撤銷時間(軟刪除)")
revoked_by = Column(String(36), nullable=True, comment="撤銷者 keycloak_user_id")
# 通用欄位
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
role = relationship("UserRole", back_populates="user_assignments")
def __repr__(self):
return f"<UserRoleAssignment user={self.keycloak_user_id} role={self.role_id}>"

View File

@@ -0,0 +1,77 @@
"""
訂閱記錄 Model
管理租戶的訂閱狀態和歷史
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Date, DateTime, Boolean, ForeignKey, Numeric
from sqlalchemy.orm import relationship
import enum
from app.db.base import Base
class SubscriptionStatus(str, enum.Enum):
"""訂閱狀態"""
ACTIVE = "active" # 進行中
CANCELLED = "cancelled" # 已取消
EXPIRED = "expired" # 已過期
class Subscription(Base):
"""訂閱記錄表"""
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
# 方案資訊
plan_id = Column(String(50), nullable=False, comment="方案 ID (starter/standard/enterprise)")
start_date = Column(Date, nullable=False, comment="開始日期")
end_date = Column(Date, nullable=False, comment="結束日期")
status = Column(String(20), default=SubscriptionStatus.ACTIVE, nullable=False, comment="狀態")
# 自動續約
auto_renew = Column(Boolean, default=True, nullable=False, comment="是否自動續約")
# 時間記錄
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
cancelled_at = Column(DateTime, nullable=True, comment="取消時間")
# 關聯
tenant = relationship("Tenant", back_populates="subscriptions")
def __repr__(self):
return f"<Subscription {self.plan_id} for Tenant#{self.tenant_id} ({self.start_date} ~ {self.end_date})>"
@property
def is_active(self) -> bool:
"""是否為活躍訂閱"""
today = date.today()
return (
self.status == SubscriptionStatus.ACTIVE and
self.start_date <= today <= self.end_date
)
@property
def days_remaining(self) -> int:
"""剩餘天數"""
if not self.is_active:
return 0
return (self.end_date - date.today()).days
def renew(self, months: int = 1) -> "Subscription":
"""續約 (創建新的訂閱記錄)"""
from dateutil.relativedelta import relativedelta
new_start = self.end_date + relativedelta(days=1)
new_end = new_start + relativedelta(months=months) - relativedelta(days=1)
return Subscription(
tenant_id=self.tenant_id,
plan_id=self.plan_id,
start_date=new_start,
end_date=new_end,
status=SubscriptionStatus.ACTIVE,
auto_renew=self.auto_renew
)

View File

@@ -0,0 +1,111 @@
"""
SystemFunction Model
系統功能明細檔
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
from sqlalchemy.sql import func
from app.db.base import Base
class SystemFunction(Base):
"""系統功能明細"""
__tablename__ = "system_functions"
# 1. 資料編號 (PK, 自動編號從 10 開始, 1~9 為功能設定編號)
id = Column(Integer, primary_key=True, index=True, comment="資料編號")
# 2. 系統功能代碼/功能英文名稱
code = Column(String(200), nullable=False, index=True, comment="系統功能代碼/功能英文名稱")
# 3. 上層功能代碼 (0 為初始層)
upper_function_id = Column(
Integer,
nullable=False,
server_default="0",
index=True,
comment="上層功能代碼 (0為初始層)"
)
# 4. 系統功能中文名稱
name = Column(String(200), nullable=False, comment="系統功能中文名稱")
# 5. 系統功能類型 (1:node, 2:function)
function_type = Column(
Integer,
nullable=False,
index=True,
comment="系統功能類型 (1:node, 2:function)"
)
# 6. 系統功能次序
order = Column(Integer, nullable=False, comment="系統功能次序")
# 7. 功能圖示
function_icon = Column(
String(200),
nullable=False,
server_default="",
comment="功能圖示"
)
# 8. 功能模組名稱 (function_type=2 必填)
module_code = Column(
String(200),
nullable=True,
comment="功能模組名稱 (function_type=2 必填)"
)
# 9. 模組項目 (JSON: [View, Create, Read, Update, Delete, Print, File])
module_functions = Column(
JSON,
nullable=False,
server_default="[]",
comment="模組項目 (View,Create,Read,Update,Delete,Print,File)"
)
# 10. 說明 (富文本格式)
description = Column(
Text,
nullable=False,
server_default="",
comment="說明 (富文本格式)"
)
# 11. 系統管理
is_mana = Column(
Boolean,
nullable=False,
server_default="true",
comment="系統管理"
)
# 12. 啟用
is_active = Column(
Boolean,
nullable=False,
server_default="true",
index=True,
comment="啟用"
)
# 13. 資料建立者
edit_by = Column(Integer, nullable=False, comment="資料建立者")
# 14. 資料最新建立時間
created_at = Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
comment="資料最新建立時間"
)
# 15. 資料最新修改時間
updated_at = Column(
DateTime(timezone=True),
nullable=True,
onupdate=func.now(),
comment="資料最新修改時間"
)
def __repr__(self):
return f"<SystemFunction(id={self.id}, code={self.code}, name={self.name})>"

View File

@@ -0,0 +1,31 @@
"""
系統功能快取 Model
從 System Admin 服務同步的系統功能定義 (只讀副本)
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint, Index
from app.db.base import Base
class SystemFunctionCache(Base):
"""系統功能快取表"""
__tablename__ = "system_functions_cache"
__table_args__ = (
UniqueConstraint("function_code", name="uq_function_code"),
Index("idx_func_cache_service", "service_code"),
Index("idx_func_cache_category", "function_category"),
)
id = Column(Integer, primary_key=True, comment="與 System Admin 的 id 一致")
service_code = Column(String(50), nullable=False, comment="服務代碼: hr/erp/mail/ai")
function_code = Column(String(100), nullable=False, comment="功能代碼: HR_EMPLOYEE_VIEW")
function_name = Column(String(200), nullable=False, comment="功能名稱")
function_category = Column(String(50), nullable=True,
comment="功能分類: query/manage/approve/report")
is_active = Column(Boolean, default=True, nullable=False)
synced_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="最後同步時間")
def __repr__(self):
return f"<SystemFunctionCache {self.function_code} ({self.service_code})>"

View File

@@ -0,0 +1,114 @@
"""
租戶 Model
多租戶 SaaS 的核心 - 每個客戶公司對應一個租戶
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text
from sqlalchemy.orm import relationship
import enum
from app.db.base import Base
class TenantStatus(str, enum.Enum):
"""租戶狀態"""
TRIAL = "trial" # 試用中
ACTIVE = "active" # 正常使用
SUSPENDED = "suspended" # 暫停 (逾期未付款)
DELETED = "deleted" # 已刪除
class Tenant(Base):
"""租戶表 (客戶組織)"""
__tablename__ = "tenants"
# 基本欄位
id = Column(Integer, primary_key=True, index=True)
code = Column(String(50), unique=True, nullable=False, index=True, comment="租戶代碼 (英文,例如 porscheworld)")
name = Column(String(200), nullable=False, comment="公司名稱")
name_eng = Column(String(200), nullable=True, comment="公司英文名稱")
# SSO 整合
keycloak_realm = Column(String(100), unique=True, nullable=True, index=True,
comment="Keycloak Realm 名稱 (等同 code每個組織一個獨立 Realm)")
# 公司資訊
tax_id = Column(String(20), nullable=True, comment="統一編號")
prefix = Column(String(10), nullable=False, default="ORG", comment="員工編號前綴 (例如 PWD → PWD0001)")
domain = Column(String(100), nullable=True, comment="主網域 (例如 porscheworld.tw)")
domain_set = Column(Text, nullable=True, comment="網域集合 (JSON Array例如 [\"ease.taipei\", \"lab.taipei\"])")
tel = Column(String(50), nullable=True, comment="公司電話")
add = Column(String(500), nullable=True, comment="公司地址")
url = Column(String(200), nullable=True, comment="公司網站")
# 訂閱與方案
plan_id = Column(String(50), nullable=False, default="starter", comment="方案 ID (starter/standard/enterprise)")
max_users = Column(Integer, nullable=False, default=5, comment="最大用戶數")
storage_quota_gb = Column(Integer, nullable=False, default=100, comment="總儲存配額 (GB)")
# 狀態管理
status = Column(String(20), default=TenantStatus.TRIAL, nullable=False, comment="狀態")
is_sysmana = Column(Boolean, default=False, nullable=False, comment="是否為系統管理公司 (管理其他租戶)")
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
# 初始化狀態
is_initialized = Column(Boolean, default=False, nullable=False, comment="是否已完成初始化設定")
initialized_at = Column(DateTime, nullable=True, comment="初始化完成時間")
initialized_by = Column(String(255), nullable=True, comment="執行初始化的使用者名稱")
# 時間記錄(通用欄位)
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
# 關聯
departments = relationship(
"Department",
back_populates="tenant",
cascade="all, delete-orphan",
lazy="dynamic"
)
employees = relationship(
"Employee",
back_populates="tenant",
cascade="all, delete-orphan",
lazy="dynamic"
)
user_roles = relationship(
"UserRole",
back_populates="tenant",
cascade="all, delete-orphan",
lazy="dynamic"
)
def __repr__(self):
return f"<Tenant {self.code} - {self.name}>"
# is_active 已改為資料庫欄位,移除 @property
@property
def is_trial(self) -> bool:
"""是否為試用狀態"""
return self.status == TenantStatus.TRIAL
@property
def total_users(self) -> int:
"""總用戶數"""
return self.employees.count()
@property
def is_over_user_limit(self) -> bool:
"""是否超過用戶數限制"""
return self.total_users > self.max_users
@property
def domains(self):
"""網域列表(從 domain_set JSON 解析)"""
if not self.domain_set:
return []
import json
try:
return json.loads(self.domain_set)
except:
return []

View File

@@ -0,0 +1,119 @@
"""
租戶網域 Model
支援單一租戶使用多個網域 (多品牌/國際化)
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
import enum
from app.db.base import Base
class DomainStatus(str, enum.Enum):
"""網域狀態"""
PENDING = "pending" # 待驗證
ACTIVE = "active" # 啟用中
DISABLED = "disabled" # 已停用
class TenantDomain(Base):
"""租戶網域表 (一個租戶可以有多個網域)"""
__tablename__ = "tenant_domains"
__table_args__ = (
# 每個租戶只能有一個主要網域
Index("idx_tenant_primary_domain", "tenant_id", unique=True, postgresql_where=Column("is_primary") == True),
# 一般索引
Index("idx_tenant_domains_tenant", "tenant_id"),
Index("idx_tenant_domains_status", "status"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
domain = Column(String(100), unique=True, nullable=False, index=True, comment="網域名稱 (abc.com.tw)")
# 網域屬性
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要網域")
status = Column(String(20), default=DomainStatus.PENDING, nullable=False, comment="狀態")
verified = Column(Boolean, default=False, nullable=False, comment="DNS 驗證狀態")
# DNS 驗證
verification_token = Column(String(100), nullable=True, comment="驗證 Token")
verified_at = Column(DateTime, nullable=True, comment="驗證時間")
# 服務啟用狀態
enable_email = Column(Boolean, default=True, nullable=False, comment="啟用郵件服務")
enable_webmail = Column(Boolean, default=True, nullable=False, comment="啟用 WebMail")
enable_drive = Column(Boolean, default=True, nullable=False, comment="啟用雲端硬碟")
# 時間記錄
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 關聯
tenant = relationship("Tenant", back_populates="domains")
email_aliases = relationship(
"UserEmailAlias",
back_populates="domain",
cascade="all, delete-orphan",
lazy="dynamic"
)
def __repr__(self):
return f"<TenantDomain {self.domain} ({'主要' if self.is_primary else '次要'})>"
@property
def is_verified(self) -> bool:
"""是否已驗證"""
return self.verified and self.status == DomainStatus.ACTIVE
def generate_dns_records(self) -> list:
"""生成 DNS 驗證記錄指引"""
records = []
# TXT 記錄 - 網域所有權驗證
records.append({
"type": "TXT",
"name": "@",
"value": f"porsche-cloud-verify={self.verification_token}",
"purpose": "網域所有權驗證"
})
if self.enable_email:
# MX 記錄 - 郵件伺服器
records.append({
"type": "MX",
"name": "@",
"value": "mail.porschecloud.tw",
"priority": 10,
"purpose": "郵件伺服器"
})
# SPF 記錄 - 防止郵件偽造
records.append({
"type": "TXT",
"name": "@",
"value": "v=spf1 include:porschecloud.tw ~all",
"purpose": "郵件 SPF 記錄"
})
if self.enable_webmail:
# CNAME - WebMail
records.append({
"type": "CNAME",
"name": "mail",
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
"purpose": "WebMail 訪問"
})
if self.enable_drive:
# CNAME - 雲端硬碟
records.append({
"type": "CNAME",
"name": "drive",
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
"purpose": "雲端硬碟訪問"
})
return records

View File

@@ -0,0 +1,56 @@
"""
使用量記錄 Model
記錄租戶和用戶的資源使用情況
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.db.base import Base
class UsageLog(Base):
"""使用量記錄表 (每日統計)"""
__tablename__ = "usage_logs"
__table_args__ = (
UniqueConstraint("tenant_id", "user_id", "date", name="uq_usage_tenant_user_date"),
)
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True, index=True)
date = Column(Date, nullable=False, index=True, comment="統計日期")
# 郵件使用量
email_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="郵件儲存 (GB)")
emails_sent = Column(Integer, default=0, nullable=False, comment="發送郵件數")
emails_received = Column(Integer, default=0, nullable=False, comment="接收郵件數")
# 雲端硬碟使用量
drive_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="硬碟儲存 (GB)")
files_uploaded = Column(Integer, default=0, nullable=False, comment="上傳檔案數")
files_downloaded = Column(Integer, default=0, nullable=False, comment="下載檔案數")
# 時間記錄
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<UsageLog Tenant#{self.tenant_id} User#{self.user_id} {self.date}>"
@property
def total_storage_gb(self) -> float:
"""總儲存使用量 (GB)"""
return float(self.email_storage_gb) + float(self.drive_storage_gb)
@classmethod
def get_or_create(cls, tenant_id: int, user_id: int = None, log_date: date = None):
"""獲取或創建當日記錄"""
if log_date is None:
log_date = date.today()
return cls(
tenant_id=tenant_id,
user_id=user_id,
date=log_date
)

View File

@@ -0,0 +1,51 @@
"""
用戶郵件別名 Model
支援員工在不同網域擁有多個郵件地址
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.db.base import Base
class UserEmailAlias(Base):
"""用戶郵件別名表 (一個用戶可以有多個郵件地址)"""
__tablename__ = "user_email_aliases"
__table_args__ = (
# 每個用戶只能有一個主要郵件
Index("idx_user_primary_email", "user_id", unique=True, postgresql_where=Column("is_primary") == True),
# 一般索引
Index("idx_email_aliases_user", "user_id"),
Index("idx_email_aliases_tenant", "tenant_id"),
Index("idx_email_aliases_domain", "domain_id"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
domain_id = Column(Integer, ForeignKey("tenant_domains.id", ondelete="CASCADE"), nullable=False, index=True)
email = Column(String(150), unique=True, nullable=False, index=True, comment="郵件地址 (sales@brand-a.com)")
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要郵件地址")
# 時間記錄
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# 關聯
user = relationship("Employee", back_populates="email_aliases")
domain = relationship("TenantDomain", back_populates="email_aliases")
def __repr__(self):
return f"<UserEmailAlias {self.email} ({'主要' if self.is_primary else '別名'})>"
@property
def local_part(self) -> str:
"""郵件前綴 (@ 之前的部分)"""
return self.email.split('@')[0] if '@' in self.email else self.email
@property
def domain_part(self) -> str:
"""網域部分 (@ 之後的部分)"""
return self.email.split('@')[1] if '@' in self.email else ""

View File

@@ -0,0 +1,107 @@
"""
Schemas 模組
匯出所有 Pydantic Schemas
"""
# Base
from app.schemas.base import (
BaseSchema,
TimestampSchema,
PaginationParams,
PaginatedResponse,
)
# Employee
from app.schemas.employee import (
EmployeeBase,
EmployeeCreate,
EmployeeUpdate,
EmployeeInDB,
EmployeeResponse,
EmployeeListItem,
EmployeeDetail,
)
# Business Unit
from app.schemas.business_unit import (
BusinessUnitBase,
BusinessUnitCreate,
BusinessUnitUpdate,
BusinessUnitInDB,
BusinessUnitResponse,
BusinessUnitListItem,
)
# Department
from app.schemas.department import (
DepartmentBase,
DepartmentCreate,
DepartmentUpdate,
DepartmentResponse,
DepartmentListItem,
DepartmentTreeNode,
)
# Employee Identity
from app.schemas.employee_identity import (
EmployeeIdentityBase,
EmployeeIdentityCreate,
EmployeeIdentityUpdate,
EmployeeIdentityInDB,
EmployeeIdentityResponse,
EmployeeIdentityListItem,
)
# Network Drive
from app.schemas.network_drive import (
NetworkDriveBase,
NetworkDriveCreate,
NetworkDriveUpdate,
NetworkDriveInDB,
NetworkDriveResponse,
NetworkDriveListItem,
NetworkDriveQuotaUpdate,
)
# Audit Log
from app.schemas.audit_log import (
AuditLogBase,
AuditLogCreate,
AuditLogInDB,
AuditLogResponse,
AuditLogListItem,
AuditLogFilter,
)
# Email Account
from app.schemas.email_account import (
EmailAccountBase,
EmailAccountCreate,
EmailAccountUpdate,
EmailAccountInDB,
EmailAccountResponse,
EmailAccountListItem,
EmailAccountQuotaUpdate,
)
# Permission
from app.schemas.permission import (
PermissionBase,
PermissionCreate,
PermissionUpdate,
PermissionInDB,
PermissionResponse,
PermissionListItem,
PermissionBatchCreate,
PermissionFilter,
VALID_SYSTEMS,
VALID_ACCESS_LEVELS,
)
# Response
from app.schemas.response import (
ResponseModel,
ErrorResponse,
MessageResponse,
SuccessResponse,
)

View File

@@ -0,0 +1,107 @@
"""
審計日誌 Schemas
"""
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import Field, ConfigDict
from app.schemas.base import BaseSchema
class AuditLogBase(BaseSchema):
"""審計日誌基礎 Schema"""
action: str = Field(..., max_length=50, description="操作類型 (create/update/delete/login)")
resource_type: str = Field(..., max_length=50, description="資源類型 (employee/identity/department)")
resource_id: Optional[int] = Field(None, description="資源 ID")
performed_by: str = Field(..., max_length=100, description="操作者 SSO 帳號")
details: Optional[Dict[str, Any]] = Field(None, description="詳細變更內容")
ip_address: Optional[str] = Field(None, max_length=45, description="IP 位址")
class AuditLogCreate(AuditLogBase):
"""創建審計日誌 Schema"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"action": "create",
"resource_type": "employee",
"resource_id": 1,
"performed_by": "porsche.chen@lab.taipei",
"details": {
"employee_id": "EMP001",
"legal_name": "陳保時",
"username_base": "porsche.chen"
},
"ip_address": "10.1.0.245"
}
}
)
class AuditLogInDB(AuditLogBase):
"""資料庫中的審計日誌 Schema"""
id: int
performed_at: datetime
model_config = ConfigDict(from_attributes=True)
class AuditLogResponse(AuditLogInDB):
"""審計日誌響應 Schema"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"action": "create",
"resource_type": "employee",
"resource_id": 1,
"performed_by": "porsche.chen@lab.taipei",
"performed_at": "2020-01-01T12:00:00",
"details": {
"employee_id": "EMP001",
"legal_name": "陳保時",
"username_base": "porsche.chen"
},
"ip_address": "10.1.0.245"
}
}
)
class AuditLogListItem(BaseSchema):
"""審計日誌列表項 Schema"""
id: int
action: str
resource_type: str
resource_id: Optional[int] = None
performed_by: str
performed_at: datetime
model_config = ConfigDict(from_attributes=True)
class AuditLogFilter(BaseSchema):
"""審計日誌篩選參數"""
action: Optional[str] = None
resource_type: Optional[str] = None
resource_id: Optional[int] = None
performed_by: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
model_config = ConfigDict(
json_schema_extra={
"example": {
"action": "create",
"resource_type": "employee",
"start_date": "2020-01-01T00:00:00",
"end_date": "2020-12-31T23:59:59"
}
}
)

View File

@@ -0,0 +1,55 @@
"""
認證相關 Schemas
"""
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
class TokenResponse(BaseModel):
"""Token 響應"""
access_token: str = Field(..., description="Access Token")
token_type: str = Field(default="bearer", description="Token 類型")
expires_in: int = Field(..., description="過期時間 (秒)")
refresh_token: Optional[str] = Field(None, description="Refresh Token")
model_config = ConfigDict(
json_schema_extra={
"example": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 1800,
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
)
class UserInfo(BaseModel):
"""用戶資訊"""
sub: str = Field(..., description="用戶 ID (Keycloak UUID)")
username: str = Field(..., description="用戶名稱")
email: str = Field(..., description="郵件地址")
first_name: Optional[str] = Field(None, description="名字")
last_name: Optional[str] = Field(None, description="姓氏")
email_verified: bool = Field(False, description="郵件是否已驗證")
tenant: Optional[Dict[str, Any]] = Field(None, description="租戶資訊")
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
"""登入請求"""
username: str = Field(..., min_length=3, description="用戶名稱")
password: str = Field(..., min_length=6, description="密碼")
model_config = ConfigDict(
json_schema_extra={
"example": {
"username": "porsche.chen@lab.taipei",
"password": "your-password"
}
}
)

Some files were not shown because too many files have changed in this diff Show More