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,211 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import axios from 'axios'
import { onboardingService } from '../onboarding.service'
import type { OnboardingRequest, OnboardingResponse, EmployeeStatusResponse } from '../../types/onboarding'
// Mock axios
vi.mock('axios', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}))
const mockedAxios = axios as unknown as {
post: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
}
describe('OnboardingService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('onboardEmployee', () => {
it('should successfully onboard an employee', async () => {
// Arrange
const request: OnboardingRequest = {
resume_id: 1,
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
keycloak_username: 'wang.ming',
hire_date: '2026-02-21',
departments: [
{
department_id: 9,
position: '資深工程師',
membership_type: 'permanent',
},
],
role_ids: [1, 2],
storage_quota_gb: 20,
email_quota_mb: 5120,
}
const mockResponse: OnboardingResponse = {
message: 'Employee onboarded successfully',
employee: {
tenant_id: 1,
seq_no: 1,
tenant_emp_code: 'PWD0001',
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
keycloak_username: 'wang.ming',
name: '王明',
hire_date: '2026-02-21',
},
summary: {
departments_assigned: 1,
roles_assigned: 2,
services_enabled: 5,
},
}
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await onboardingService.onboardEmployee(request)
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
'http://localhost:10181/api/v1/emp-lifecycle/onboard',
request
)
expect(result).toEqual(mockResponse)
})
it('should throw error when API fails', async () => {
// Arrange
const request: OnboardingRequest = {
resume_id: 999,
keycloak_user_id: 'invalid-uuid',
keycloak_username: 'test',
hire_date: '2026-02-21',
departments: [],
role_ids: [],
}
const errorMessage = 'Resume ID 999 not found'
mockedAxios.post.mockRejectedValueOnce({
response: {
data: { detail: errorMessage },
status: 404,
},
})
// Act & Assert
await expect(
onboardingService.onboardEmployee(request)
).rejects.toThrow()
})
})
describe('getEmployeeStatus', () => {
it('should fetch employee status successfully', async () => {
// Arrange
const tenantId = 1
const seqNo = 1
const mockResponse: EmployeeStatusResponse = {
employee: {
tenant_id: 1,
seq_no: 1,
tenant_emp_code: 'PWD0001',
name: '王明',
keycloak_user_id: '550e8400-e29b-41d4-a716-446655440000',
keycloak_username: 'wang.ming',
hire_date: '2026-02-21',
resign_date: null,
employment_status: 'active',
storage_quota_gb: 20,
email_quota_mb: 5120,
},
departments: [
{
department_id: 9,
department_name: '玄鐵風能',
position: '資深工程師',
membership_type: 'permanent',
joined_at: '2026-02-21T10:00:00',
},
],
roles: [
{
role_id: 1,
role_name: 'HR管理員',
role_code: 'HR_ADMIN',
assigned_at: '2026-02-21T10:00:00',
},
],
services: [
{
service_id: 1,
service_name: '單一簽入',
service_code: 'SSO',
quota_gb: null,
quota_mb: null,
enabled_at: '2026-02-21T10:00:00',
},
],
}
mockedAxios.get.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await onboardingService.getEmployeeStatus(tenantId, seqNo)
// Assert
expect(mockedAxios.get).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/emp-lifecycle/${tenantId}/${seqNo}/status`
)
expect(result).toEqual(mockResponse)
})
it('should throw error when employee not found', async () => {
// Arrange
const tenantId = 1
const seqNo = 999
mockedAxios.get.mockRejectedValueOnce({
response: {
data: { detail: 'Employee not found' },
status: 404,
},
})
// Act & Assert
await expect(
onboardingService.getEmployeeStatus(tenantId, seqNo)
).rejects.toThrow()
})
})
describe('offboardEmployee', () => {
it('should successfully offboard an employee', async () => {
// Arrange
const tenantId = 1
const seqNo = 1
const mockResponse = {
message: 'Employee offboarded successfully',
employee: {
tenant_emp_code: 'PWD0001',
resign_date: '2026-02-21',
},
summary: {
departments_removed: 1,
roles_revoked: 2,
services_disabled: 5,
},
}
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await onboardingService.offboardEmployee(tenantId, seqNo)
// Assert
expect(mockedAxios.post).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/emp-lifecycle/${tenantId}/${seqNo}/offboard`
)
expect(result).toEqual(mockResponse)
})
})
})

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import axios from 'axios'
import { tenantService } from '../tenant.service'
import type { Tenant, TenantUpdateRequest, TenantUpdateResponse } from '../../types/tenant'
// Mock axios
vi.mock('axios', () => ({
default: {
get: vi.fn(),
put: vi.fn(),
},
}))
const mockedAxios = axios as unknown as {
get: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
}
describe('TenantService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getTenant', () => {
it('should fetch tenant information successfully', async () => {
// Arrange
const tenantId = 1
const mockTenant: Tenant = {
id: 1,
code: 'PWD',
name: 'Porsche World',
name_eng: 'Porsche World Co., Ltd.',
tax_id: '12345678',
prefix: 'PWD',
tel: '02-1234-5678',
add: '台北市信義區...',
url: 'https://porscheworld.tw',
plan_id: 'starter',
max_users: 50,
storage_quota_gb: 1000,
status: 'active',
is_sysmana: false,
is_active: true,
created_at: '2026-01-01T00:00:00',
updated_at: '2026-02-21T00:00:00',
}
mockedAxios.get.mockResolvedValueOnce({ data: mockTenant })
// Act
const result = await tenantService.getTenant(tenantId)
// Assert
expect(mockedAxios.get).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/tenants/${tenantId}`
)
expect(result).toEqual(mockTenant)
})
it('should throw error when tenant not found', async () => {
// Arrange
const tenantId = 999
mockedAxios.get.mockRejectedValueOnce({
response: {
data: { detail: 'Tenant not found' },
status: 404,
},
})
// Act & Assert
await expect(tenantService.getTenant(tenantId)).rejects.toThrow()
})
})
describe('updateTenant', () => {
it('should update tenant information successfully', async () => {
// Arrange
const tenantId = 1
const updateData: TenantUpdateRequest = {
name: 'Porsche World Updated',
tax_id: '87654321',
tel: '02-8765-4321',
add: '台北市大安區...',
url: 'https://newporscheworld.tw',
}
const mockResponse: TenantUpdateResponse = {
message: 'Tenant updated successfully',
tenant: {
id: 1,
code: 'PWD',
name: 'Porsche World Updated',
name_eng: 'Porsche World Co., Ltd.',
tax_id: '87654321',
prefix: 'PWD',
tel: '02-8765-4321',
add: '台北市大安區...',
url: 'https://newporscheworld.tw',
plan_id: 'starter',
max_users: 50,
storage_quota_gb: 1000,
status: 'active',
is_sysmana: false,
is_active: true,
created_at: '2026-01-01T00:00:00',
updated_at: '2026-02-21T10:00:00',
},
}
mockedAxios.put.mockResolvedValueOnce({ data: mockResponse })
// Act
const result = await tenantService.updateTenant(tenantId, updateData)
// Assert
expect(mockedAxios.put).toHaveBeenCalledWith(
`http://localhost:10181/api/v1/tenants/${tenantId}`,
updateData
)
expect(result).toEqual(mockResponse)
})
it('should throw error when update fails', async () => {
// Arrange
const tenantId = 1
const updateData: TenantUpdateRequest = {
name: 'Test',
}
mockedAxios.put.mockRejectedValueOnce({
response: {
data: { detail: 'Update failed' },
status: 400,
},
})
// Act & Assert
await expect(tenantService.updateTenant(tenantId, updateData)).rejects.toThrow()
})
})
})

View File

@@ -0,0 +1,47 @@
/**
* 事業單位管理 API 服務
*/
import { apiClient } from '@/lib/api-client'
import type { BusinessUnit, PaginatedResponse } from '@/types'
export const businessUnitsService = {
/**
* 取得事業單位列表
*/
getBusinessUnits: async (params?: {
page?: number
page_size?: number
department_id?: string
status?: string
}): Promise<PaginatedResponse<BusinessUnit>> => {
return apiClient.get('/business-units', { params })
},
/**
* 取得單一事業單位詳情
*/
getBusinessUnit: async (id: string): Promise<BusinessUnit> => {
return apiClient.get(`/business-units/${id}`)
},
/**
* 建立事業單位
*/
createBusinessUnit: async (data: Partial<BusinessUnit>): Promise<BusinessUnit> => {
return apiClient.post('/business-units', data)
},
/**
* 更新事業單位資料
*/
updateBusinessUnit: async (id: string, data: Partial<BusinessUnit>): Promise<BusinessUnit> => {
return apiClient.put(`/business-units/${id}`, data)
},
/**
* 刪除事業單位
*/
deleteBusinessUnit: async (id: string): Promise<void> => {
return apiClient.delete(`/business-units/${id}`)
},
}

View File

@@ -0,0 +1,46 @@
/**
* 部門管理 API 服務
*/
import { apiClient } from '@/lib/api-client'
import type { Department, PaginatedResponse } from '@/types'
export const departmentsService = {
/**
* 取得部門列表
*/
getDepartments: async (params?: {
page?: number
page_size?: number
status?: string
}): Promise<PaginatedResponse<Department>> => {
return apiClient.get('/departments', { params })
},
/**
* 取得單一部門詳情
*/
getDepartment: async (id: string): Promise<Department> => {
return apiClient.get(`/departments/${id}`)
},
/**
* 建立部門
*/
createDepartment: async (data: Partial<Department>): Promise<Department> => {
return apiClient.post('/departments', data)
},
/**
* 更新部門資料
*/
updateDepartment: async (id: string, data: Partial<Department>): Promise<Department> => {
return apiClient.put(`/departments/${id}`, data)
},
/**
* 刪除部門
*/
deleteDepartment: async (id: string): Promise<void> => {
return apiClient.delete(`/departments/${id}`)
},
}

View File

@@ -0,0 +1,73 @@
/**
* 郵件帳號服務
* 處理所有與郵件帳號相關的 API 請求
*/
import { apiClient } from '@/lib/api-client'
import type {
EmailAccount,
CreateEmailAccountInput,
UpdateEmailAccountInput,
EmailAccountQuotaUpdate,
PaginatedResponse,
} from '@/types'
export interface EmailAccountListParams {
page?: number
page_size?: number
employee_id?: number
is_active?: boolean
search?: string
}
export const emailAccountsService = {
/**
* 獲取郵件帳號列表
*/
async list(params?: EmailAccountListParams): Promise<PaginatedResponse<EmailAccount>> {
return apiClient.get<PaginatedResponse<EmailAccount>>('/email-accounts', {
params,
})
},
/**
* 獲取郵件帳號詳情
*/
async get(id: number): Promise<EmailAccount> {
return apiClient.get<EmailAccount>(`/email-accounts/${id}`)
},
/**
* 創建郵件帳號
*/
async create(data: CreateEmailAccountInput): Promise<EmailAccount> {
return apiClient.post<EmailAccount>('/email-accounts', data)
},
/**
* 更新郵件帳號
*/
async update(id: number, data: UpdateEmailAccountInput): Promise<EmailAccount> {
return apiClient.put<EmailAccount>(`/email-accounts/${id}`, data)
},
/**
* 更新郵件配額
*/
async updateQuota(id: number, data: EmailAccountQuotaUpdate): Promise<EmailAccount> {
return apiClient.patch<EmailAccount>(`/email-accounts/${id}/quota`, data)
},
/**
* 停用郵件帳號 (軟刪除)
*/
async delete(id: number): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(`/email-accounts/${id}`)
},
/**
* 獲取員工的所有郵件帳號
*/
async getByEmployee(employeeId: number): Promise<EmailAccount[]> {
return apiClient.get<EmailAccount[]>(`/email-accounts/employees/${employeeId}/email-accounts`)
},
}

View File

@@ -0,0 +1,75 @@
/**
* 員工管理 API 服務
*/
import { apiClient } from '@/lib/api-client'
import type {
Employee,
CreateEmployeeInput,
UpdateEmployeeInput,
PaginatedResponse,
EmailAccount,
} from '@/types'
export const employeesService = {
/**
* 取得員工列表
*/
getEmployees: async (params?: {
page?: number
page_size?: number
status?: string
department_id?: string
search?: string
}): Promise<PaginatedResponse<Employee>> => {
return apiClient.get('/employees', { params })
},
/**
* 取得單一員工詳情
*/
getEmployee: async (id: string): Promise<Employee> => {
return apiClient.get(`/employees/${id}`)
},
/**
* 建立員工
*/
createEmployee: async (data: CreateEmployeeInput): Promise<Employee> => {
return apiClient.post('/employees', data)
},
/**
* 更新員工資料
*/
updateEmployee: async (id: string, data: UpdateEmployeeInput): Promise<Employee> => {
return apiClient.put(`/employees/${id}`, data)
},
/**
* 刪除員工
*/
deleteEmployee: async (id: string): Promise<void> => {
return apiClient.delete(`/employees/${id}`)
},
/**
* 取得員工的郵件帳號列表 (符合 WebMail 設計規範)
*/
getEmployeeEmailAccounts: async (userId: string): Promise<{ user_id: string; email_accounts: EmailAccount[] }> => {
return apiClient.get(`/employees/${userId}/email-accounts`)
},
/**
* 員工離職處理
*/
resignEmployee: async (id: string, resignationDate: string): Promise<Employee> => {
return apiClient.post(`/employees/${id}/resign`, { resignation_date: resignationDate })
},
/**
* 員工復職處理
*/
reactivateEmployee: async (id: string): Promise<Employee> => {
return apiClient.post(`/employees/${id}/reactivate`)
},
}

View File

@@ -0,0 +1,46 @@
import axios from 'axios'
import type {
OnboardingRequest,
OnboardingResponse,
EmployeeStatusResponse,
} from '../types/onboarding'
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:10181'
class OnboardingService {
/**
* 員工到職
*/
async onboardEmployee(request: OnboardingRequest): Promise<OnboardingResponse> {
const response = await axios.post<OnboardingResponse>(
`${API_BASE_URL}/api/v1/emp-lifecycle/onboard`,
request
)
return response.data
}
/**
* 查詢員工狀態
*/
async getEmployeeStatus(
tenantId: number,
seqNo: number
): Promise<EmployeeStatusResponse> {
const response = await axios.get<EmployeeStatusResponse>(
`${API_BASE_URL}/api/v1/emp-lifecycle/${tenantId}/${seqNo}/status`
)
return response.data
}
/**
* 員工離職
*/
async offboardEmployee(tenantId: number, seqNo: number) {
const response = await axios.post(
`${API_BASE_URL}/api/v1/emp-lifecycle/${tenantId}/${seqNo}/offboard`
)
return response.data
}
}
export const onboardingService = new OnboardingService()

View File

@@ -0,0 +1,83 @@
/**
* 系統權限服務
* 處理所有與系統權限相關的 API 請求
*/
import { apiClient } from '@/lib/api-client'
import type {
Permission,
CreatePermissionInput,
UpdatePermissionInput,
PermissionBatchCreateInput,
SystemInfo,
PaginatedResponse,
SystemName,
AccessLevel,
} from '@/types'
export interface PermissionListParams {
page?: number
page_size?: number
employee_id?: number
system_name?: SystemName
access_level?: AccessLevel
}
export const permissionsService = {
/**
* 獲取權限列表
*/
async list(params?: PermissionListParams): Promise<PaginatedResponse<Permission>> {
return apiClient.get<PaginatedResponse<Permission>>('/permissions', {
params,
})
},
/**
* 獲取權限詳情
*/
async get(id: number): Promise<Permission> {
return apiClient.get<Permission>(`/permissions/${id}`)
},
/**
* 創建權限
*/
async create(data: CreatePermissionInput): Promise<Permission> {
return apiClient.post<Permission>('/permissions', data)
},
/**
* 更新權限
*/
async update(id: number, data: UpdatePermissionInput): Promise<Permission> {
return apiClient.put<Permission>(`/permissions/${id}`, data)
},
/**
* 刪除權限
*/
async delete(id: number): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(`/permissions/${id}`)
},
/**
* 獲取員工的所有系統權限
*/
async getByEmployee(employeeId: number): Promise<Permission[]> {
return apiClient.get<Permission[]>(`/permissions/employees/${employeeId}/permissions`)
},
/**
* 批量創建權限
*/
async batchCreate(data: PermissionBatchCreateInput): Promise<Permission[]> {
return apiClient.post<Permission[]>('/permissions/batch', data)
},
/**
* 獲取可授權的系統列表
*/
async getSystems(): Promise<SystemInfo> {
return apiClient.get<SystemInfo>('/permissions/systems')
},
}

View File

@@ -0,0 +1,73 @@
/**
* System Function Service
* 系統功能列表服務
*/
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:10181';
export interface SystemFunctionNode {
id: number;
code: string;
name: string;
function_type: number; // 1=NODE, 2=FUNCTION
order: number;
function_icon: string;
module_code: string | null;
module_functions: string[]; // ["View", "Create", "Read", "Update", "Delete"]
description: string;
children: SystemFunctionNode[];
}
export const systemFunctionService = {
/**
* 取得功能列表樹狀結構
* @param isSysmana 是否為系統管理公司
* @returns Promise<SystemFunctionNode[]>
*/
async getMenuTree(isSysmana: boolean = false): Promise<SystemFunctionNode[]> {
try {
const url = `${API_BASE_URL}/api/v1/system-functions/menu/tree?is_sysmana=${isSysmana}`;
console.log('[SystemFunctionService] Fetching menu tree:', url);
console.log('[SystemFunctionService] is_sysmana parameter:', isSysmana);
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 包含 cookies (用於認證)
});
if (!response.ok) {
throw new Error(`Failed to fetch menu tree: ${response.statusText}`);
}
const data = await response.json();
console.log('[SystemFunctionService] Received menu items:', data.length);
console.log('[SystemFunctionService] Menu data:', data);
return data;
} catch (error) {
console.error('Error fetching menu tree:', error);
throw error;
}
},
/**
* 將 code 轉換為路由路徑
* @param code 功能代碼 (例如: tenant_departments)
* @returns 路由路徑 (例如: /tenant-departments)
*/
codeToRoute(code: string): string {
return `/${code.replace(/_/g, '-')}`;
},
/**
* 檢查功能是否有特定操作權限
* @param moduleFunctions 功能操作列表
* @param operation 操作名稱 (View, Create, Read, Update, Delete)
* @returns boolean
*/
hasOperation(moduleFunctions: string[], operation: string): boolean {
return moduleFunctions.includes(operation);
},
};

View File

@@ -0,0 +1,60 @@
import axios from 'axios'
import type {
Tenant,
TenantUpdateRequest,
TenantUpdateResponse,
TenantListResponse,
TenantCreateRequest,
TenantCreateResponse,
} from '../types/tenant'
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:10181'
class TenantService {
/**
* Get all tenants
*/
async getTenants(): Promise<TenantListResponse> {
const response = await axios.get<TenantListResponse>(
`${API_BASE_URL}/api/v1/tenants`
)
return response.data
}
/**
* Get tenant information by ID
*/
async getTenant(tenantId: number): Promise<Tenant> {
const response = await axios.get<Tenant>(
`${API_BASE_URL}/api/v1/tenants/${tenantId}`
)
return response.data
}
/**
* Create new tenant
*/
async createTenant(data: TenantCreateRequest): Promise<TenantCreateResponse> {
const response = await axios.post<TenantCreateResponse>(
`${API_BASE_URL}/api/v1/tenants`,
data
)
return response.data
}
/**
* Update tenant information
*/
async updateTenant(
tenantId: number,
data: TenantUpdateRequest
): Promise<TenantUpdateResponse> {
const response = await axios.put<TenantUpdateResponse>(
`${API_BASE_URL}/api/v1/tenants/${tenantId}`,
data
)
return response.data
}
}
export const tenantService = new TenantService()