06 / WORK / CASE STUDY
高科大設備借用管理系統
條碼借還、盤點、賠償與用餐基金記帳的系所內部系統;2025 年起獨立開發並部署。
- TYPE
- FULL STACK
- SCOPE
- 已部署 · 獨立開發
- STACK
- DJANGO · DRF · MYSQL · REACT · PLAYWRIGHT
系所的設備借還原本依附在一套舊平台的模組上,資料髒、難維護;「用餐基金」則是一份人工維護的 Excel。這個系統同時承接兩件事:把設備借還(含逾期、賠償、盤點、套組)系統化,並把基金記帳轉成有帳戶餘額、結算期間與現金核對的正式帳。
- 要承接舊系統資料庫,舊借用紀錄大量欄位為空、品質差。
- 櫃檯借還是高頻操作、使用者非技術背景,單筆流程必須在幾秒內完成。
- 單台 VPS、無容器與 Redis:快取用行程內 LocMemCache,速率限制改用 DB cache 以跨 worker 共享。
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
04 / 負責範圍RESPONSIBILITIES
前後端獨立開發(git 全歷史單一作者,2025/09–2026/07):資料模型、API、權限、React 前端、測試策略與部署(nginx + gunicorn systemd)。
05
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 與重跑;交易寫入自動維護餘額,月底有現金核對機制。
遷移的防呆要做在 mapper 層:2,271 筆錯誤幾乎全是同一種空欄位問題,先清洗再匯入,整份錯誤報表就不會存在。
把錯誤靜默成「一切正常」比壞掉更危險。後期 UIUX 稽核點出的重複頁面與假資料顯示,是快速堆功能階段留下的債——現在有清單、有編號地在還。
以下畫面皆為示範(mock)資料,不含任何真實使用者資料。
櫃檯借用流程一路操作到最後一步:搜尋學生自動帶出班級與電話,連續加入三件設備,只差按下「確認借用」——這一步刻意沒按,所以畫面上沒有任何已寫入的結果。頂端「逾期未還 13」是即時算出來的,不是資料庫欄位。
儀表板把六個統計磚、近七天趨勢圖、使用率條與最近借用記錄放在一頁。這次截圖跑在一次性 SQLite 上的合成種子資料(200 件設備、4,000 名學生皆由 Faker 產生),專案正式環境用的是 MySQL。
設備管理中心:左側分類樹同時依類別與地點切分,右側是 200 筆清單加批次匯入、匯出與批次列印條碼。「設備編號」欄是空的——這是拍攝當下發現的真實 bug:前端 column 宣告 dataIndex 為 code,API 實際序列化的欄位叫 serial_number。資料在回應裡,只是讀錯 key;沒有為了讓截圖好看去改它。
借用紀錄查詢:關鍵字、日期區間、狀態三段篩選加 CSV 匯出,135 筆分頁列出。可延期的單子才會出現「延期」——後端把單次延長限制在 1 到 30 天,超出範圍直接擋在 API。
使用率分析把 200 件設備依累計借用次數排名,每列帶平均借用天數與最後借用時間,用來找出誰是熱門品項、誰在架上生灰。順帶一提:序號在這一頁正常顯示——同一份 API 資料,只是這裡的 column 設定沒有踩到設備管理頁那個讀錯 key 的問題。
同一個借用畫面在 834px 平板寬度:側欄收成漢堡選單,設備清單從表格改排成雙欄卡片,欄位變成標籤/值的直列。這不是把表格橫向捲動了事,而是換一種資訊排法。
390px 手機寬度的同一張儀表板:統計磚改成雙欄、圖表的日期軸標籤轉直排、其餘區塊依序堆疊。桌機、平板、手機三種寬度都是同一支 Playwright 腳本對著跑起來的 app 實拍,不是縮圖模擬。09
09 / 架構圖ARCHITECTURE DIAGRAMS
以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。
一張借用單同時驅動三組狀態機:單頭 6 種狀態、明細行 4 種、設備 4 種,圖上每條轉移都標了實際觸發它的端點。兩件事只有畫成圖才看得見:「逾期」根本不是儲存欄位,而是由 status 與 due_at 即時推導(靠 borrow_status_due_idx 撐住查詢);而 lost / damaged 兩個明細狀態雖然定義了、篩選器也讀得到,卻沒有任何正式路徑會寫入它們。
POST /borrow-tickets/{id}/approve/ 從 nginx 到 MySQL 的完整往返——全系統最有意思的一條寫入路徑。核准時每個明細行都先 SELECT … FOR UPDATE 鎖住設備列,這正是兩位管理員同時核准同一件設備不會雙重借出的原因。同一次核准會被三套彼此獨立的機制各記一筆:ORM signal 寫 ModelChangeLog(涵蓋 11 個列管模型)、middleware 寫 AuditLog、view 明寫領域層的 BorrowStatusLog。
授權其實是四層疊起來的:middleware 鏈、依序嘗試的三種認證方式、DRF 權限類別,再加上 view 內的逐列範圍限縮(例如非管理員只看得到自己開的單)。右側逐條列出每個路由實際落在哪一層。圖上也標出 RBAC 停在哪裡:Role/Capability 有建模、有種子資料、會回傳給前端做按鈕灰化,但伺服器端真正讀 capability 的只有備份模組;其餘實質上是 is_staff 說了算。這是設計現況,不是圖畫錯了。
單台 VPS 上真正跑起來的形狀:Cloudflare → nginx → gunicorn 三個 sync worker → MySQL 8,十三個領域 app 共用同一個行程。這張圖刻意把「不存在的東西」也畫進去:沒有 Celery、沒有 Redis、沒有排程器,所以逾期通知信、Excel 匯出、PDF 條碼標籤與 demo 重置全都同步跑在 web worker 裡,而唯一跨 worker 共享的狀態是速率限制用的 DatabaseCache——限制條件長成什麼樣,架構就會長成什麼樣。
核心領域 schema,用的是真實 MySQL 表名與欄位型別(repo 裡沒有任何 db_table 覆寫,所以圖上的名字就是資料庫裡的名字)。兩個設計決定從圖上看得最清楚:明細行的 equipment_id 允許為 NULL,好讓椅子、鑰匙這類沒有建檔的臨時品項也能掛在同一張單上,再用兩條 partial unique constraint 分別擋掉重複設備與重複臨時品名;而 master data 一律以 PROTECT 相連,借過的設備刪不掉。