跳至主要內容

06 / WORK / CASE STUDY

高科大設備借用管理系統

條碼借還、盤點、賠償與用餐基金記帳的系所內部系統;2025 年起獨立開發並部署。

TYPE
FULL STACK
SCOPE
已部署 · 獨立開發
STACK
DJANGO · DRF · MYSQL · REACT · PLAYWRIGHT
LINKS
LIVE

01 / PROBLEM

系所的設備借還原本依附在一套舊平台的模組上,資料髒、難維護;「用餐基金」則是一份人工維護的 Excel。這個系統同時承接兩件事:把設備借還(含逾期、賠償、盤點、套組)系統化,並把基金記帳轉成有帳戶餘額、結算期間與現金核對的正式帳。

02 / CONSTRAINTS

  • 要承接舊系統資料庫,舊借用紀錄大量欄位為空、品質差。
  • 櫃檯借還是高頻操作、使用者非技術背景,單筆流程必須在幾秒內完成。
  • 單台 VPS、無容器與 Redis:快取用行程內 LocMemCache,速率限制改用 DB cache 以跨 worker 共享。

03 / ARCHITECTURE

Django 5 + DRF 模組化單體,依領域切成 13 個 app(borrowing、finance、audit、inventory、kits⋯),28 個資料模型、24 個資源路由加 87 個自訂 action;前端 React 19 + Ant Design,40 個頁面。JWT 放 HttpOnly cookie 並做 refresh rotation 與 blacklist;RBAC 以 Role/Capability 建模。

稽核是獨立子系統:middleware 自動記錄操作(含敏感操作標記、耗時、回應狀態),另有欄位級的模型變更記錄。財務子系統做用餐基金:交易寫入時自動維護帳戶餘額,支援結算期間與現金核對,整套帳可從原始 Excel 一鍵匯入重建。

04 / RESPONSIBILITIES

前後端獨立開發(git 全歷史單一作者,2025/09–2026/07):資料模型、API、權限、React 前端、測試策略與部署(nginx + gunicorn systemd)。

05 / CHALLENGES → SOLUTIONS

CHALLENGE

舊系統的借用紀錄要搬進新 schema,但資料髒到直接匯入必然失敗。

SOLUTION

寫了一組遷移工具(每個實體一個 mapper、獨立 verifier、支援 dry-run 與報表),舊庫以獨立 connection 隔離。一次實跑 180 秒搬入 9,138 筆借用單、3,336 名學生與 370 件設備;同時有 2,271 筆舊紀錄因空欄位報錯——這批錯誤被完整留在遷移報表裡,成為列管中的資料品質債,而不是被吞掉。

CHALLENGE

櫃檯要快:人工輸入太慢,而且桌機與手機的掃描硬體完全不同。

SOLUTION

整條流程條碼化:後端以 Code128 產生設備標籤,前端依裝置自動切換模式——桌機走掃碼槍輸入、手機開相機(@zxing),支援批次連掃與同碼短時間去重,一掃帶出設備與借用單。

CHALLENGE

用餐基金的 Excel 有多年新舊資料混雜,轉成正式帳不能弄丟一筆。

SOLUTION

寫成兩遍式匯入命令:第一遍建帳戶與收支類別,第二遍處理期初餘額、結算期間與逐筆交易,整體包在資料庫交易裡,支援 dry-run 與重跑;交易寫入自動維護餘額,月底有現金核對機制。

06 / SYSTEM FACTS

13

領域模組

86

自訂 API actions

1,181

後端測試函式

36

Playwright E2E

07 / LESSONS

遷移的防呆要做在 mapper 層:2,271 筆錯誤幾乎全是同一種空欄位問題,先清洗再匯入,整份錯誤報表就不會存在。

把錯誤靜默成「一切正常」比壞掉更危險。後期 UIUX 稽核點出的重複頁面與假資料顯示,是快速堆功能階段留下的債——現在有清單、有編號地在還。

08 / SCREENS

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

借還作業中心的借用分頁:左側以學號搜尋帶出學生班級與電話,右側是預計歸還日期與備註,下方設備清單已加入三件影音設備,底部是確認借用按鈕
櫃檯借用流程一路操作到最後一步:搜尋學生自動帶出班級與電話,連續加入三件設備,只差按下「確認借用」——這一步刻意沒按,所以畫面上沒有任何已寫入的結果。頂端「逾期未還 13」是即時算出來的,不是資料庫欄位。
儀表板:設備總數、借出中、可借用、學生總數、今日借出與今日歸還六個統計磚,近七天借還長條圖,借用率與可用率進度條,以及最近借用記錄表格
儀表板把六個統計磚、近七天趨勢圖、使用率條與最近借用記錄放在一頁。這次截圖跑在一次性 SQLite 上的合成種子資料(200 件設備、4,000 名學生皆由 Faker 產生),專案正式環境用的是 MySQL。
設備管理中心:左側依類別與地點展開的分類樹、右側 200 筆設備表格,上方有新增設備、批次匯入、匯出清單與批次列印條碼按鈕;設備編號欄位顯示為空白
設備管理中心:左側分類樹同時依類別與地點切分,右側是 200 筆清單加批次匯入、匯出與批次列印條碼。「設備編號」欄是空的——這是拍攝當下發現的真實 bug:前端 column 宣告 dataIndex 為 code,API 實際序列化的欄位叫 serial_number。資料在回應裡,只是讀錯 key;沒有為了讓截圖好看去改它。
借用紀錄查詢頁:關鍵字、日期區間與狀態三個篩選欄位,匯出 CSV 按鈕,以及 135 筆借用單的分頁表格,部分列帶有延期動作
借用紀錄查詢:關鍵字、日期區間、狀態三段篩選加 CSV 匯出,135 筆分頁列出。可延期的單子才會出現「延期」——後端把單次延長限制在 1 到 30 天,超出範圍直接擋在 API。
設備使用率分析頁:總設備數、借用中設備、累計借用次數與平均借用天數四個統計磚,下方是依借用次數排名的設備明細表,含序號、分類、地點、狀態與最後借用時間
使用率分析把 200 件設備依累計借用次數排名,每列帶平均借用天數與最後借用時間,用來找出誰是熱門品項、誰在架上生灰。順帶一提:序號在這一頁正常顯示——同一份 API 資料,只是這裡的 column 設定沒有踩到設備管理頁那個讀錯 key 的問題。
同一個借還作業中心在平板寬度下的樣子:側欄收成漢堡選單,設備清單從表格改排成雙欄卡片,每張卡片列出設備編號、名稱、類別與存放地點
同一個借用畫面在 834px 平板寬度:側欄收成漢堡選單,設備清單從表格改排成雙欄卡片,欄位變成標籤/值的直列。這不是把表格橫向捲動了事,而是換一種資訊排法。
390px 手機寬度的儀表板:六個統計磚排成雙欄、長條圖的日期軸標籤轉為直排、使用率條與最近借用記錄依序往下堆疊
390px 手機寬度的同一張儀表板:統計磚改成雙欄、圖表的日期軸標籤轉直排、其餘區塊依序堆疊。桌機、平板、手機三種寬度都是同一支 Playwright 腳本對著跑起來的 app 實拍,不是縮圖模擬。

09 / ARCHITECTURE DIAGRAMS

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

狀態圖:借用單(6 種狀態)、借用明細行(4 種)與設備(4 種)三組狀態機,每條轉移標註觸發它的 API 端點
一張借用單同時驅動三組狀態機:單頭 6 種狀態、明細行 4 種、設備 4 種,圖上每條轉移都標了實際觸發它的端點。兩件事只有畫成圖才看得見:「逾期」根本不是儲存欄位,而是由 status 與 due_at 即時推導(靠 borrow_status_due_idx 撐住查詢);而 lost / damaged 兩個明細狀態雖然定義了、篩選器也讀得到,卻沒有任何正式路徑會寫入它們。
序列圖:核准借用單的請求從 React SPA 經 nginx、gunicorn、稽核與安全 middleware、DRF 認證授權,到 view 內的資料庫交易與三種稽核寫入
POST /borrow-tickets/{id}/approve/ 從 nginx 到 MySQL 的完整往返——全系統最有意思的一條寫入路徑。核准時每個明細行都先 SELECT … FOR UPDATE 鎖住設備列,這正是兩位管理員同時核准同一件設備不會雙重借出的原因。同一次核准會被三套彼此獨立的機制各記一筆:ORM signal 寫 ModelChangeLog(涵蓋 11 個列管模型)、middleware 寫 AuditLog、view 明寫領域層的 BorrowStatusLog。
流程圖:授權的四個層次——middleware 鏈、依序嘗試的三種認證方式、DRF 權限類別、view 內的逐列範圍限縮——並逐條列出每個路由實際落在哪一層
授權其實是四層疊起來的:middleware 鏈、依序嘗試的三種認證方式、DRF 權限類別,再加上 view 內的逐列範圍限縮(例如非管理員只看得到自己開的單)。右側逐條列出每個路由實際落在哪一層。圖上也標出 RBAC 停在哪裡:Role/Capability 有建模、有種子資料、會回傳給前端做按鈕灰化,但伺服器端真正讀 capability 的只有備份模組;其餘實質上是 is_staff 說了算。這是設計現況,不是圖畫錯了。
架構圖:Cloudflare 到 nginx、gunicorn 三個 worker、Django 十三個領域 app、MySQL 與兩組快取,並標示沒有 Celery、Redis 或排程器
單台 VPS 上真正跑起來的形狀:Cloudflare → nginx → gunicorn 三個 sync worker → MySQL 8,十三個領域 app 共用同一個行程。這張圖刻意把「不存在的東西」也畫進去:沒有 Celery、沒有 Redis、沒有排程器,所以逾期通知信、Excel 匯出、PDF 條碼標籤與 demo 重置全都同步跑在 web worker 裡,而唯一跨 worker 共享的狀態是速率限制用的 DatabaseCache——限制條件長成什麼樣,架構就會長成什麼樣。
ERD:借用單、明細行、賠償、設備、分類、地點、學生與帳號權限等核心資料表,標註真實的 MySQL 表名、欄位型別與外鍵刪除行為
核心領域 schema,用的是真實 MySQL 表名與欄位型別(repo 裡沒有任何 db_table 覆寫,所以圖上的名字就是資料庫裡的名字)。兩個設計決定從圖上看得最清楚:明細行的 equipment_id 允許為 NULL,好讓椅子、鑰匙這類沒有建檔的臨時品項也能掛在同一張單上,再用兩條 partial unique constraint 分別擋掉重複設備與重複臨時品名;而 master data 一律以 PROTECT 相連,借過的設備刪不掉。

NEXT

大型 Django 微服務全通路商務平台