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,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")