跳至主要內容

01 / WORK / CASE STUDY

多租戶 SaaS 人資系統

涵蓋出勤、排班、薪資、簽核到招募的多租戶人資平台,36 個功能模組;目前處於驗收與修復階段。

TYPE
FULL STACK
SCOPE
公司內部 · 驗收修復階段
STACK
DJANGO · DRF · MYSQL · REDIS · CELERY · REACT
LINKS
公司內部專案(不公開)

01 / PROBLEM

中小企業的 HR 事務常散落在 Excel、紙本簽核與各自為政的工具之間,難以稽核也容易出錯,且每家公司都要重複導入一次。這個系統以多租戶 SaaS 的形態切入:單一部署服務多個組織,各租戶資料嚴格隔離、依訂閱開關功能模組,並內建台灣勞基法的合規檢查。

02 / CONSTRAINTS

  • 公司內部專案,程式碼不公開;目前以合成種子資料進行驗收,尚未正式上線。
  • 多租戶隔離是硬性要求:任何一條查詢漏掉租戶條件都是資料外洩。
  • 需符合台灣勞基法(工時上限、加班倍率、扣繳)並支援繁中/簡中/英文三語。

03 / ARCHITECTURE

模組化單體:Django 4.2+ 與 DRF 依領域切成 36 個功能 app(出勤、排班、薪資、簽核、招募、資產、勞健保⋯),241 個資料模型、188 個資源路由,展開後在驗收中實測了 728 個端點。刻意不拆微服務——薪資與簽核這類跨模組流程在單一交易邊界裡簡單得多。

多租戶採 shared-schema、row-level 隔離:middleware 解析租戶,241 個模型中有 186 個帶租戶欄位、繼承租戶感知基底。非同步層以 Celery + Beat 處理班表產生、出勤歸檔與法規/假日同步;Redis 作快取與 Channel;資料庫可依環境切換 PostgreSQL 或 MySQL;CI 用 GitHub Actions + pre-commit,並附 Prometheus / Grafana / Loki 可觀測性編排。

04 / RESPONSIBILITIES

前後端獨立開發(後端 434、前端 123 個 commit 皆出自我的兩組 git 身分):資料模型與多租戶機制、權限體系、各領域模組、Celery 背景任務、React 前端與 CI。

05 / CHALLENGES → SOLUTIONS

CHALLENGE

資安稽核發現:部分自訂 API action 繞過了租戶過濾,可跨租戶操作帳號。

SOLUTION

把隔離下沉到基底(TenantAwareModel + middleware 統一解析),逐一修補繞過 queryset 的 action。該輪稽核共 35 項發現(含 2 項 Critical)全數確認,30 項修復完成、5 項部分修復,尚待 staging 環境複驗——這個狀態如實記錄。

CHALLENGE

薪資 API 的快取鍵沒有包含使用者身分——同一個 TTL 窗內,一般員工可能命中管理員的快取,看到全公司薪資。

SOLUTION

在快取裝飾器加入 vary_by_user,把使用者折進快取鍵;並用真實 HTTP 呼叫寫了回歸測試,證明兩個使用者互相看不到彼此的快取。

CHALLENGE

台灣的國定假日與法規參數每年變動,不能靠人工同步。

SOLUTION

Celery Beat 排程化:每年同步隔年假日、每日檢查法規到期、每週預告即將到來的假日;任務帶自動重試與指數退避。

06 / SYSTEM FACTS

36

功能模組

241

資料模型

728

端點實測

87.5%

驗收通過率

07 / LESSONS

快取設計就是權限設計——薪資快取洩漏的根因,是快取鍵少了身分維度。在多租戶系統裡,這種錯誤不是效能問題,是資料外洩。

系統性的漏洞往往來自模型設計:全域角色與租戶角色雙軌並存,連帶引發提權與跨租戶問題。重來一次,我會從第一天就統一以租戶維度設計權限。

08 / SCREENS

以下畫面皆為示範(mock)資料,不含任何真實使用者資料。

薪資管理頁:年份/月份/狀態篩選、人數與總薪資 KPI,以及本薪、加項、扣項、實發金額與已計算/已發放狀態的薪資表
薪資管理:試算、批次建立與匯出收在同一頁。畫面上每一個金額,在資料庫裡都是加密欄位——payroll_records 的 18 個金額欄位全數使用 EncryptedDecimalField。資料為合成種子資料。
同一個薪資模組的 DRF browsable API:11 個自訂 action 連結、viewset docstring 渲染出的 OWASP 安全註記,以及 {data, pagination} 信封格式的 JSON 回應
同一個薪資模組,從 API 這一側看。頁面上那兩段 OWASP A01/A02 註記是直接由 viewset 的 docstring 渲染出來的:租戶隔離與敏感欄位保護寫在程式碼裡,而不是另一份文件裡。
招聘儀表板:開放職缺/待處理應徵者/本週面試/待發 Offer 四張 KPI 卡、逐階段標示轉換率的招聘漏斗,以及各職缺的錄取進度
招聘儀表板:漏斗逐階段標出轉換率,右側是各職缺的錄取進度。數字來自種子資料(3 個職缺、5 位應徵者),不是營運數據。
排班日曆月檢視:月/週/日/列表四種檢視切換、早班/午班/晚班/全天班的色彩圖例,以及自動排班與新增班次
排班月檢視,班別以顏色標示。「自動排班」對應後端的 scheduling.generate_monthly_schedules——同一個任務也掛在 Celery Beat 上,每月 25 日產生下個月班表。
390px 寬的排班畫面:改為逐日的日程列表版面,配長按拖放提示、底部分頁列與浮動新增按鈕
同一個排班模組在 390px 寬。不是把月曆縮小,而是換成專屬的日程列表版面,導覽也從側邊欄改為底部分頁列。
drf-spectacular 產生的 Swagger UI,v1 群組展開,列出 tenants 相關的 GET/POST/PUT/PATCH/DELETE 端點
drf-spectacular 由 188 條路由註冊自動產生的 OpenAPI 3.0 文件。截圖只是開頭一小段:整份 schema 有 1,222 條路徑、1,919 個操作,完整渲染高度約 21 萬像素。

09 / ARCHITECTURE DIAGRAMS

以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。

多租戶隔離四層圖:middleware 解析租戶、view 層 TenantFilterMixin 過濾、ORM 層刻意不做隱式 scope,以及四類已修復的歷史繞過路徑
這張圖的重點不是「有做隔離」,而是隔離究竟落在哪一層。租戶由 middleware 依序從 header、子網域、使用者主租戶解析,再由 view 層的 TenantFilterMixin 過濾;ORM 刻意不做隱式 scope——TenantAwareManager 只過濾 is_deleted,repo 裡有一條測試專門斷言這件事。代價寫在圖的下半:200 個 viewset 中 155 個繼承過濾 mixin,另外 45 個仍是原生 DRF base、只能逐一手動加條件;而歷史上四類已修復的漏洞,無一例外都發生在 ORM 之上的 view 與 cache 程式碼裡。
執行期架構圖:gunicorn 與 ASGI 兩個入口、Django 請求管線、36 個 app 與非同步層,以及 PostgreSQL/Redis、LDAP/Keycloak 與可觀測性堆疊
執行期全貌:HTTP 走 gunicorn、WebSocket 走 ASGI,同一條 middleware 管線收斂到 36 個 app、241 個模型(其中 186 個帶租戶欄位)、188 條路由。Redis 一個實例同時扮演快取、session、Celery broker 與 channel layer。右下角的 ELK 被標為 NOT WIRED——docker-compose 定義了這三個服務,但 LOGGING 裡沒有任何 handler 餵它,所以照實畫成虛線。
請假送出與核准的序列圖:交易邊界、假期餘額列的 SELECT ... FOR UPDATE、審計日誌經 Celery 寫入與 WebSocket 推播
請假從送出到核准的完整往返。真正值得看的是額度帳本:送出時鎖住餘額列(SELECT ... FOR UPDATE)把 pending_days 加上去,核准時在同一把鎖裡把 pending 轉成 used——取消則反向還原。審計日誌預設丟給 Celery 的 audit_logs 佇列,但 dispatch 失敗會退回同步寫入,不會靜默丟失。
Celery 拓撲圖:37 條 beat 排程、task_routes 路由規則、7 個宣告的隊列與 worker,以及 9 條指向未註冊 task 的排程
非同步層的實際樣貌:37 條 beat 排程、task_routes 的前綴路由、7 個帶優先級的隊列。這張圖真正的價值在紅色那一區——37 條排程裡有 9 條指向不存在的 task:名稱與 @shared_task 的註冊名不符、模組根本沒建(apps/assets/tasks.py、apps/insurance/tasks.py)、或函式從未定義。另外 emails 隊列沒有任何生產者,crawlers 隊列被排程引用卻不在 task_queues 裡。已知未修,照原樣畫出來。
18 張核心資料表的 ERD:租戶、成員、員工、組織、請假、出勤、薪資、簽核流程與審計日誌,欄位與資料表名皆為真名
18 張核心資料表,db_table 與欄位都是真名。圖裡藏著多租戶設計的兩個關鍵決定:accounts_user 上沒有 tenant_id——歸屬完全走 tenants_tenant_user 這張成員表,所以一個帳號可以屬於多個租戶;而 accounts_user.employee_id 是全域唯一,不是每租戶唯一。這兩點合起來,說明了為什麼隔離只能寫在查詢層,而不是靠資料表的唯一鍵擋下來。

NEXT

AI 美甲平台