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

378
scripts/init-db.sql Normal file
View File

@@ -0,0 +1,378 @@
-- ====================================
-- HR Portal Database Schema
-- ====================================
-- 創建時間: 2026-02-08
-- 資料庫: PostgreSQL 16
-- ====================================
-- 設定時區
SET timezone = 'Asia/Taipei';
-- ====================================
-- 1. 事業部表 (Business Units)
-- ====================================
CREATE TABLE IF NOT EXISTS business_units (
id SERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
name_en VARCHAR(100),
description TEXT,
manager_id INTEGER, -- 將在建立 employees 表後設定外鍵
email_domain VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 初始資料
INSERT INTO business_units (code, name, name_en, email_domain, description) VALUES
('wind-energy', '玄鐵風能授權服務事業部', 'Wind Energy Licensing', 'ease.taipei', '風力發電技術授權與風場評估服務'),
('carbon-credit', '國際碳權申請服務事業部', 'Carbon Credit Services', 'ease.taipei', '碳權申請、碳盤查與碳交易媒合服務'),
('smart-rd', '智能研發服務事業部', 'Smart R&D Services', 'lab.taipei', 'AI/ML、IoT 與能源管理系統研發'),
('management', '管理部門', 'Management', 'porscheworld.tw', '人資、財務、行政、資訊等管理部門')
ON CONFLICT (code) DO NOTHING;
-- ====================================
-- 2. 部門表 (Divisions)
-- ====================================
CREATE TABLE IF NOT EXISTS divisions (
id SERIAL PRIMARY KEY,
business_unit_id INTEGER REFERENCES business_units(id) ON DELETE CASCADE,
code VARCHAR(50) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
name_en VARCHAR(100),
manager_id INTEGER, -- 將在建立 employees 表後設定外鍵
email VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 初始資料 - 玄鐵風能授權服務
INSERT INTO divisions (business_unit_id, code, name, name_en, email) VALUES
(1, 'wind-licensing', '技術授權部', 'Technical Licensing', 'wind-licensing@ease.taipei'),
(1, 'wind-assessment', '風場評估部', 'Wind Assessment', 'wind-assessment@ease.taipei'),
(1, 'wind-service', '客戶服務部', 'Customer Service', 'wind-service@ease.taipei')
ON CONFLICT (code) DO NOTHING;
-- 國際碳權申請服務
INSERT INTO divisions (business_unit_id, code, name, name_en, email) VALUES
(2, 'carbon-apply', '碳權申請部', 'Carbon Application', 'carbon-apply@ease.taipei'),
(2, 'carbon-audit', '碳盤查部', 'Carbon Audit', 'carbon-audit@ease.taipei'),
(2, 'carbon-trade', '碳交易部', 'Carbon Trading', 'carbon-trade@ease.taipei')
ON CONFLICT (code) DO NOTHING;
-- 智能研發服務
INSERT INTO divisions (business_unit_id, code, name, name_en, email) VALUES
(3, 'software-dev', '軟體研發部', 'Software Development', 'software@lab.taipei'),
(3, 'hardware-dev', '硬體研發部', 'Hardware Development', 'hardware@lab.taipei'),
(3, 'product-mgmt', '產品管理部', 'Product Management', 'product@lab.taipei')
ON CONFLICT (code) DO NOTHING;
-- 管理部門
INSERT INTO divisions (business_unit_id, code, name, name_en, email) VALUES
(4, 'hr', '人力資源部', 'Human Resources', 'hr@porscheworld.tw'),
(4, 'finance', '財務部', 'Finance', 'finance@porscheworld.tw'),
(4, 'admin', '行政部', 'Administration', 'admin@porscheworld.tw'),
(4, 'it', '資訊部', 'Information Technology', 'it@porscheworld.tw')
ON CONFLICT (code) DO NOTHING;
-- ====================================
-- 3. 員工表 (Employees)
-- ====================================
CREATE TABLE IF NOT EXISTS employees (
id SERIAL PRIMARY KEY,
keycloak_user_id UUID UNIQUE,
employee_id VARCHAR(20) UNIQUE NOT NULL,
username VARCHAR(100) UNIQUE NOT NULL,
-- 基本資料
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
chinese_name VARCHAR(50),
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
mobile VARCHAR(20),
-- 任職資訊
business_unit_id INTEGER REFERENCES business_units(id),
division_id INTEGER REFERENCES divisions(id),
team VARCHAR(100),
position VARCHAR(100),
job_title VARCHAR(100),
job_level VARCHAR(20), -- C-Level, VP, Director, Manager, Senior, Staff, etc.
employment_type VARCHAR(20) DEFAULT 'full-time', -- full-time, part-time, contractor, intern
-- 日期
hire_date DATE,
termination_date DATE,
-- 狀態
status VARCHAR(20) DEFAULT 'active', -- active, inactive, suspended, terminated
-- 審計
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
updated_by VARCHAR(100)
);
-- 索引
CREATE INDEX idx_employees_keycloak_id ON employees(keycloak_user_id);
CREATE INDEX idx_employees_status ON employees(status);
CREATE INDEX idx_employees_business_unit ON employees(business_unit_id);
CREATE INDEX idx_employees_division ON employees(division_id);
CREATE INDEX idx_employees_email ON employees(email);
-- 新增外鍵到 business_units 和 divisions
ALTER TABLE business_units ADD CONSTRAINT fk_business_unit_manager
FOREIGN KEY (manager_id) REFERENCES employees(id) ON DELETE SET NULL;
ALTER TABLE divisions ADD CONSTRAINT fk_division_manager
FOREIGN KEY (manager_id) REFERENCES employees(id) ON DELETE SET NULL;
-- ====================================
-- 4. 郵件帳號表 (Email Accounts)
-- ====================================
CREATE TABLE IF NOT EXISTS email_accounts (
id SERIAL PRIMARY KEY,
employee_id INTEGER REFERENCES employees(id) ON DELETE CASCADE,
email_address VARCHAR(255) UNIQUE NOT NULL,
mailbox_quota_mb INTEGER DEFAULT 1024,
mailbox_used_mb INTEGER DEFAULT 0,
-- 郵件設定
forward_to VARCHAR(255),
auto_reply BOOLEAN DEFAULT FALSE,
auto_reply_message TEXT,
-- 狀態
is_active BOOLEAN DEFAULT TRUE,
-- 審計
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100)
);
CREATE INDEX idx_email_accounts_employee ON email_accounts(employee_id);
CREATE INDEX idx_email_accounts_email ON email_accounts(email_address);
-- ====================================
-- 5. 網路硬碟表 (Network Drives)
-- ====================================
CREATE TABLE IF NOT EXISTS network_drives (
id SERIAL PRIMARY KEY,
employee_id INTEGER REFERENCES employees(id) ON DELETE CASCADE,
-- 硬碟資訊
drive_name VARCHAR(100) NOT NULL,
drive_path VARCHAR(500) NOT NULL,
quota_gb INTEGER DEFAULT 10,
used_gb DECIMAL(10,2) DEFAULT 0,
-- 存取設定
webdav_url VARCHAR(500),
smb_path VARCHAR(500),
-- 權限
can_share BOOLEAN DEFAULT FALSE,
-- 狀態
is_active BOOLEAN DEFAULT TRUE,
-- 審計
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_network_drives_employee ON network_drives(employee_id);
-- ====================================
-- 6. 系統權限表 (System Permissions)
-- ====================================
CREATE TABLE IF NOT EXISTS system_permissions (
id SERIAL PRIMARY KEY,
employee_id INTEGER REFERENCES employees(id) ON DELETE CASCADE,
system_name VARCHAR(100) NOT NULL,
system_url VARCHAR(500),
access_level VARCHAR(50), -- admin, user, readonly
-- 狀態
is_active BOOLEAN DEFAULT TRUE,
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
granted_by VARCHAR(100),
revoked_at TIMESTAMP,
revoked_by VARCHAR(100),
UNIQUE(employee_id, system_name)
);
CREATE INDEX idx_system_permissions_employee ON system_permissions(employee_id);
CREATE INDEX idx_system_permissions_system ON system_permissions(system_name);
-- ====================================
-- 7. 專案表 (Projects)
-- ====================================
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
business_unit_id INTEGER REFERENCES business_units(id),
project_code VARCHAR(50) UNIQUE NOT NULL,
project_name VARCHAR(200) NOT NULL,
description TEXT,
client_name VARCHAR(200),
-- 專案狀態
status VARCHAR(20) DEFAULT 'planning', -- planning, active, on-hold, completed, cancelled
-- 專案經理
project_manager_id INTEGER REFERENCES employees(id),
-- 時間
start_date DATE,
end_date DATE,
actual_end_date DATE,
-- 預算
budget_amount DECIMAL(15,2),
budget_currency VARCHAR(10) DEFAULT 'TWD',
-- 專案空間
nas_path VARCHAR(500),
git_repo VARCHAR(200),
-- 審計
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100)
);
CREATE INDEX idx_projects_business_unit ON projects(business_unit_id);
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_manager ON projects(project_manager_id);
-- ====================================
-- 8. 專案成員表 (Project Members)
-- ====================================
CREATE TABLE IF NOT EXISTS project_members (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
employee_id INTEGER REFERENCES employees(id) ON DELETE CASCADE,
role VARCHAR(50), -- project-manager, developer, consultant, support
allocation_percentage INTEGER DEFAULT 100, -- 0-100
joined_date DATE DEFAULT CURRENT_DATE,
left_date DATE,
UNIQUE(project_id, employee_id)
);
CREATE INDEX idx_project_members_project ON project_members(project_id);
CREATE INDEX idx_project_members_employee ON project_members(employee_id);
-- ====================================
-- 9. 審計日誌表 (Audit Logs)
-- ====================================
CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
employee_id INTEGER REFERENCES employees(id),
action VARCHAR(50) NOT NULL, -- create, update, delete, login, logout
resource_type VARCHAR(50), -- employee, email, drive, project
resource_id INTEGER,
old_value JSONB,
new_value JSONB,
ip_address VARCHAR(50),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_logs_employee ON audit_logs(employee_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
-- ====================================
-- 10. 更新時間觸發器函數
-- ====================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- 為需要的表新增觸發器
CREATE TRIGGER update_business_units_updated_at BEFORE UPDATE ON business_units
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_divisions_updated_at BEFORE UPDATE ON divisions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_employees_updated_at BEFORE UPDATE ON employees
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_email_accounts_updated_at BEFORE UPDATE ON email_accounts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_network_drives_updated_at BEFORE UPDATE ON network_drives
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ====================================
-- 11. 建立視圖 (Views)
-- ====================================
-- 員工完整資訊視圖
CREATE OR REPLACE VIEW v_employees_full AS
SELECT
e.*,
bu.name as business_unit_name,
bu.name_en as business_unit_name_en,
d.name as division_name,
d.name_en as division_name_en
FROM employees e
LEFT JOIN business_units bu ON e.business_unit_id = bu.id
LEFT JOIN divisions d ON e.division_id = d.id;
-- 專案人力統計視圖
CREATE OR REPLACE VIEW v_project_stats AS
SELECT
p.id,
p.project_code,
p.project_name,
p.status,
COUNT(pm.id) as member_count,
SUM(pm.allocation_percentage) as total_allocation
FROM projects p
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.left_date IS NULL
GROUP BY p.id, p.project_code, p.project_name, p.status;
-- 部門人力統計視圖
CREATE OR REPLACE VIEW v_division_headcount AS
SELECT
bu.name as business_unit_name,
d.name as division_name,
COUNT(e.id) as employee_count,
COUNT(CASE WHEN e.status = 'active' THEN 1 END) as active_count
FROM divisions d
LEFT JOIN business_units bu ON d.business_unit_id = bu.id
LEFT JOIN employees e ON e.division_id = d.id
GROUP BY bu.name, d.name;
-- ====================================
-- 完成
-- ====================================
-- 授予權限 (依實際用戶名調整)
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO hr_user;
-- GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO hr_user;
SELECT 'HR Portal Database Schema 初始化完成!' as message;