Files
hr-portal/backend/app/api/v1/department_members.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

227 lines
6.7 KiB
Python

"""
部門成員管理 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")