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

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}>"