Files
hr-portal/backend/app/models/tenant.py
Porsche Chen 360533393f 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>
2026-02-23 20:12:43 +08:00

115 lines
4.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
租戶 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 []