跳至主要內容

05 / WORK / CASE STUDY

高科大智慧商務系系友會平台

系友名錄、系友企業與徵才的官方網站;2024 年起開發,現於校方網域營運中。

TYPE
FULL STACK
SCOPE
營運中 · 全端 + 維運
STACK
DJANGO · DRF · MYSQL · REDIS · REACT
LINKS
LIVE

01 / PROBLEM

系友會過去沒有統一的線上平台:系友名冊、合作企業、徵才與活動公告分散各處,靠人工維護。這個系統把「系友自助維護個資 → 管理員審核 → 對外公開展示」與「公司自助登錄職缺/產品」整合成單一平台,並提供傑出系友與系所的對外頁面。

02 / CONSTRAINTS

  • 掛在校方網域正式對外,存有系友個資(屬個資法規管),資安要求高。
  • 單機部署(nginx + gunicorn),無容器編排;維運、資安與 UIUX 稽核都由一人完成。
  • 前端是 CRA(非 SSR),但系友會官網必須能被搜尋引擎索引。

03 / ARCHITECTURE

Django 5 + DRF 單體(11 個業務 app、39 個資料模型,38 個資源路由加上 118 個自訂 action)搭配 React 18 SPA。權限為 deny-by-default:全域預設 IsAuthenticated,公開端點逐一顯式開放;JWT 走 HttpOnly cookie;Redis 快取熱門查詢。

自建監控子系統:middleware 把請求、錯誤與效能指標寫進 7 個監控模型,GeoIP 標註來源國別,異常寄信告警;日誌每日輪替壓縮、保存一年,自 2025 年 5 月起持續記錄至今。

04 / RESPONSIBILITIES

前後端獨立開發與維運(2024/08 起,前後端共 169 個 commit):資料模型、API、權限與資安整改、React 前端、部署與監控。

05 / CHALLENGES → SOLUTIONS

CHALLENGE

上線前的資安審查發現一串高風險漏洞:註冊 API 可自我提權、序列化器有 mass assignment、會員端點可用遞增 id 列舉個資。

SOLUTION

根因是全域 AllowAny + 逐點加鎖。把權限翻轉為 deny-by-default,以編號化流程(SEC-001~030)逐項修補:欄位白名單、擁有者過濾、敏感欄位遮罩、回應一致化防列舉——29 項修復落地,並補上針對提權與 IDOR 的回歸測試(7/7 通過)。

CHALLENGE

CRA SPA 天生對搜尋引擎不友善,而 react-snap 這類現成方案的 puppeteer 版本過舊不可用。

SOLUTION

自寫 prerender 腳本(puppeteer + Chrome for Testing):build 後對 14 條對外路由逐一渲染並寫回靜態 HTML,掛在 postbuild 自動執行;後端另建動態 sitemap,過程中順手修掉 sitemap 一律 500 的欄位錯誤。

CHALLENGE

沒有 APM 預算,但對外站台需要知道誰在打它、哪裡在變慢。

SOLUTION

自建監控:middleware 記錄請求/錯誤/效能,GeoIP 標來源,分級 throttling(登入 10/min、註冊 10/hr)擋濫用;每日輪替的請求日誌從 2025 年 5 月持續累積至今,是系統真實營運的直接證據。

06 / SYSTEM FACTS

11

業務模組

100+

API endpoints

30

SEC 稽核項(29 修復)

43

Playwright E2E

07 / LESSONS

權限要從 default-deny 起步。AllowAny 加逐點加鎖讓整片攻擊面打開,上線前靠審查補救的成本,比一開始就做對高出太多。

CRA + 自建預渲染能動,但脆弱:路由要手動維護、Chrome 版本綁死。重來會直接選有原生 SSR/SSG 的框架,省掉整條自維護管線。

08 / SCREENS

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

系友企業搜尋頁:關鍵字搜尋列、行業別篩選標籤、結果筆數與網格/列表切換,下方為企業卡片
系友企業搜尋。行業別標籤由 company_industry 資料表長出來,不是寫死的清單;卡片影像是本次示範產生的合成圖,真實企業照片留在正式資料庫裡。
系友企業職缺頁的表格檢視:職位、公司、發布時間與截止時間欄位,右上為表格/卡片檢視切換
對外職缺列表。公開端點只讀 active=True 的資料;截止日期存進資料庫也顯示在畫面上,卻沒有任何查詢過濾它——過期職缺要靠人工下架,這是設計上的缺口。
同一個職缺頁在 390px 寬手機上的樣子:表格改排為單欄卡片,導覽收合成漢堡選單
同一頁在 390px 手機上,表格改排成單欄卡片。這是整頁截圖,所以比手機一屏長得多。
後台招募職位管理頁:職缺總數與篩選結果統計卡、發布者下拉篩選、跨公司的職缺表格與檢視/編輯/刪除操作
後台職缺管理:一張表列出所有公司的職缺,可新增、編輯、下架與匯出。這條路徑由 IsAdminOrSuperUser 把關,和系友端「只能改自己公司職缺」的路徑是兩組獨立端點。
傑出系友管理頁:順序編號、名稱、摘要、「展示於官網」開關、上下排序按鈕與編輯/刪除
傑出系友管理。程式碼裡沒有提名或審核流程——管理員直接建立資料列,再用 is_featured 開關與 sort_order 決定官網呈現什麼、以什麼順序;批次排序走 bulk_update 包在單一交易裡,不是逐筆 save()。
同一個傑出系友管理頁在 390px 手機上:每一列拆成「欄位名對值」的卡片,開關、排序與編輯刪除按鈕全部保留
同一張後台表格在手機上改成逐欄位的卡片,所有操作都留著。響應式的功夫沒有只做在對外頁面,後台也一起做了。

09 / ARCHITECTURE DIAGRAMS

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

寫入路徑與公開可見性流程圖:匿名、系友、管理員三種角色各自能觸發的寫入端點、序列化器剝除的權限欄位,以及匿名讀者實際讀得到的查詢條件
誰能寫什麼,以及公開端點真正讀得回什麼。整站的對外閘門其實只有幾個布林欄位:文章與職缺看 active,系友名錄看 is_show,傑出系友看 is_featured。右下角的註記是這張圖最值得看的部分——publish_at / expire_at / deadline 都存了也回傳了,卻沒有任何查詢用到它們。
登入與已認證請求的時序圖:nginx、監控 middleware 的 IP 封鎖閘、Cookie JWT 認證與 CSRF 檢查、DRF view,以及走執行緒池的稽核寫入
一個請求從 nginx 進來、到稽核紀錄落地的完整時序。IP 封鎖在進 view 之前就擋下;cookie 路徑強制 CSRF,舊的 Bearer header 路徑則跳過。最下方那則註記是誠實的缺口:每個寫入者都傳 request_log=None,所以 query_logs、crud_logs 這些表指回 request_logs 的外鍵建了模型卻從未被填。
部署與執行架構圖:nginx 反向代理到 gunicorn(3 workers)、依序排列的 middleware 鏈、DRF 的預設拒絕層與 11 個 Django app,以及 MySQL、Redis、GeoIP、輪替日誌與郵件外部相依
單機部署的執行期全貌,middleware 依 settings.py 的實際順序排列。右下的註記寫的是這個系統的真相:沒有任何 broker——celery.py 是 0 位元組的空檔,非同步工作全部跑在 in-process 的 ThreadPoolExecutor(監控 4、稽核 2、文章圖片 5)與寄信用的 threading.Thread 上;whitenoise 躺在 requirements.txt 裡卻沒接進 MIDDLEWARE。
系友帳號的狀態圖:三條開通路徑、登入與登出、密碼重設碼、停用與刪除,每個轉換都標上對應端點與權限
帳號從三條開通路徑(自助註冊、管理員單筆建立、Excel 批次匯入)一路到停用與刪除的狀態機。停用不只翻 is_active——它在 transaction.atomic + select_for_update 裡把該使用者的每一張 OutstandingToken 加進黑名單;而既有帳號的 is_staff / is_superuser 全站只剩一個端點改得動。這是資安整改之後留下的形狀。
核心領域 ER 圖:以真實 MySQL 表名繪製系友、公司、產品、職缺、文章與稽核紀錄之間的關聯、關鍵欄位與索引名稱
核心領域的 ER 圖,節點用的是真實的資料表名與索引名,不是模型類別名。順帶照實畫出一處歷史包袱:product 與 picture 兩個 app 各自有一個叫 ProductImage 的模型,資料表形狀還不一樣。監控相關的表除了 crud_logs 之外未收進圖中。

NEXT

高科大設備借用管理系統