1 分鐘閱讀

職工福利特約商店優惠查詢

本來只是想方便一點

醫院裡的職工福利小組長期在幫同仁談特約商店優惠,買東西打折、餐飲折扣那些。

這些資料一直放在一套叫 Lotus Notes 的老系統裡。那是 Lotus 在 1989 年做出來、六年後被 IBM 買走的辦公軟體,我們這套跑了十幾年。同仁想知道有什麼優惠,得回辦公室、開電腦、打開那個專用程式。

舊的 Lotus Notes 用戶端,特約商店資料原本長這樣

小組希望能讓大家直接用手機查。

於是先做了最簡單的版本:把資料從老系統倒出來,做成一個網頁,丟到醫院原本就有的網站上。沒有登入,沒有密碼,知道網址就看得到。純粹圖方便。

上線一陣子之後,重新盤點特約合約才發現一個問題。部分合約白紙黑字寫著「本優惠僅提供予甲方所屬員工,不得轉借他人使用」。

網頁完全公開,等於誰都拿得到本來該是員工專屬的那份清單。合約那句話就沒有意義了。

這篇記錄後來怎麼幫這個網頁補上「只有員工看得到」,以及過程中踩到的幾個坑。

怎麼證明你是本院員工

這句話聽起來簡單,做起來要選。

院內本來就有一套上網要登入的系統,看起來可以直接接在後面。但很快就排除了。那套東西是連醫院 Wi-Fi 時會跳出來要你登入的那一頁,人一離開醫院就不存在了。而同仁最需要查優惠的時刻,正好是站在店家櫃檯前面的那一刻。院外要能查,這是核心需求。

第二個想法是請大家輸入身分證字號或員工編號來比對。身分證字號牽涉個資法的「特定目的外利用」,要走的程序不少;員工編號則是多數人根本記不住。

最後選的是 LINE。

醫院開一個 LINE 官方帳號,同仁加好友,第一次使用時輸入姓名和一組驗證碼完成綁定。之後每次查詢,系統靠 LINE 帳號認人,不用再輸入任何東西。

驗證碼會定期更換,透過院內原本就有的公告管道發布。看得到院內公告的人,才拿得到碼。

這裡有一個刻意的決定:系統不去查證「你是不是真的叫這個名字」。驗證碼就是唯一的關卡。

理由很直接。真正兌換優惠的那一關,本來就是在店家櫃檯出示員工證。網頁只要做到「拿不到驗證碼的人查不到」,就已經符合合約的意思了,沒必要為了追求完美的身分驗證,反過來去多蒐集一堆用不到的個資。

職工福利認證碼

東西怎麼串起來

整條路是三段:

辦公室那台老電腦(64-bit Windows,32-bit Python)
   │  用 COM API 讀 Lotus Notes,把特約優惠內容倒出來送上雲端
   ▼
Google 雲端(Cloud Functions + Firestore)
   │  確認過身分,才把資料交出去
   ▼
同仁手機裡的 LINE 畫面(LIFF 網頁)

最下面那層是 LIFF(LINE Front-end Framework),嵌在 LINE 裡面的網頁。同仁的感受是在 LINE 裡點一下就看到清單,不會意識到自己開了一個網頁,也不用另外記帳號密碼。

中間那層放在 Google 的雲端服務上:Firestore 當資料庫,後端是 Cloud Functions,也就是不用自己養主機、有人呼叫的時候才跑起來的一小段程式,這個專案用 Python 寫。所有身分檢查都在這裡做。

上面那層是辦公室那台舊電腦。機器本身是 64 位元的 Windows,但 Notes 8.5.3 的 COM 元件是 32-bit,呼叫它的行程必須跟它同位元,所以那支 Python 只能是 32-bit。

權限上它是最弱的一環,這是刻意的。它手上只有一組共用密鑰,用來打 Cloud Functions 的管理端點把資料送上去,除此之外什麼都不能動。Firebase 的 service account 金鑰全程只存在於 Google 自己的部署環境裡,不落地到那台電腦,也不進 git repo。

驗證只做一次。同仁在 LIFF 頁輸入姓名和驗證碼,後端確認三件事:LINE 登入身分是真的、驗證碼還沒過期、連續打錯沒有超過次數上限。都過了就在 Firestore 寫一筆「已驗證」,之後查詢都靠這筆紀錄放行,不用再驗證第二次。

踩過的坑

老系統這種東西,麻煩通常不在你要寫的那段程式,在它周圍。

被鎖死的不是那台機器,是那支 Python。後面一半的麻煩都是從這一條長出來的。

第一個是套件裝不上去。paramiko 依賴的 cryptography 在 PyPI 上沒有對應的 32-bit 預編譯 wheel,平常安裝是直接抓別人事先包好的成品,沒得抓,pip 只好退回去用原始碼建置。然後就卡住。

卡住的原因其實不在 32-bit,在醫院網路。

醫院的網路設備會把對外的 HTTPS 拆開來檢查,再用自己的自簽憑證重新包回去。作業系統認得那張憑證,程式自己帶的信任清單不認得,於是判定連線被攔截,直接中斷。

同一件事換個樣子又出現一次。本機腳本用 requests 打外部 API,一直回同一行:

SSL: CERTIFICATE_VERIFY_FAILED: self-signed certificate in certificate chain

可是同一台機器上 curl 打同樣的網址完全正常。原因一樣:Windows 的憑證庫信任那張自簽憑證,Python 自己帶的那套不信。

正解其實不難:把院內那張自簽 CA 加進 Python 的信任鏈就好。verify 直接指到憑證檔、設 REQUESTS_CA_BUNDLE 環境變數,或是裝 truststore 讓 Python 改讀 Windows 自己的憑證庫(這個要 Python 3.10 以上,舊環境不一定吃得到)。

我沒有這樣做。這個專案裡其他打外部 API 的腳本早就用 verify=False 繞過去了,新加的就延續同一個做法。

這是有代價的。verify=False 等於把憑證檢查整個關掉,這條連線之後被人插進來,腳本不會察覺。我接受這一條,是因為它跑在院內那台機器上、對外只打固定幾個服務,真正敏感的東西也不在這條路上。但它是我妥協的地方,不是我想出來的解法。

paramiko 則是乾脆不裝,SSH/SCP 一律呼叫 Windows 內建的 OpenSSH 執行檔,用 subprocess 包一層。

部署工具也有自己的鳥脾氣。firebase-tools 在 Windows 上第一次執行,常常卡死在上傳前的 functions discovery,重試沒用,整個砍掉重跑一次反而就過了。查了好一陣子才確定是工具本身的問題,不是我設定錯。

真正把功能弄壞的是另一件。

Cloud Functions 的密鑰(Secret Manager)不是設定好就自動生效。每一支用到它的函式,都得在自己的定義上明確宣告要注入這個密鑰。

兩支函式,我只有一支記得寫。

上線後第一次真人測試就失敗,畫面只回「伺服器設定錯誤」。查 log 才看到,忘了宣告的那一支讀到的密鑰是空字串。

而且這種錯在本機測試完全看不出來,因為本機測試會繞過雲端的環境變數注入機制。要真的部署上去才會現形。

幾件刻意沒做的事

不是所有資料都要用一樣的保護等級。

特約商店的優惠資料有合約明文限制,值得做完整的「後端驗證 + 資料庫」那一套。後來延伸做的另一個功能是查院內公告,敏感度低很多,就用了輕很多的做法:資料存成一份靜態檔案,放在一個網址本身是隨機亂碼、沒有任何頁面連得過去的位置,Cloud Functions 驗證身分通過之後才代替使用者去抓。

這不是滴水不漏,但跟資料本身的敏感度是相稱的。沒必要為了低風險的東西堆一樣重的架構。

驗證碼外流是我接受的風險,不是我要解決的問題。

任何人只要拿到當期的碼就能綁定,理論上碼流出去就防不住。我沒有為這件事再加機制。

這一層要擋的是「路人搜到網址就看得到」,不是有心人。想清楚這件事之後,很多看起來不夠嚴密的地方,其實是刻意的。

最後一件是砍掉的功能。

原本規劃裡有一項「同仁推薦店家」的線上表單。設計到一半才意識到,一旦做出來,同仁隨手推薦幾家店,職工福利小組就得逐一去追蹤、回覆進度。但他們其實想要的是被動模式,店家自己有意願才聯繫、慢慢談。

多做一個表單,反而是幫別人製造工作量。

後來直接砍掉,改成單純留電話跟信箱。門檻比按一個按鈕高,真正有意願的人才會打電話,天然篩掉了雜訊,也不用另外維護一套追蹤機制。

最後

系統目前的狀態:1位同仁完成綁定(我自己,測試階段),清單裡有 108 家特約商店,驗證碼一週換一次。

這個專案沒有用到什麼新技術。LINE Login、Firebase、Cloud Functions 都是很成熟的東西,網路上一堆教學。

麻煩的地方在於,要接的另一端是一套跑了十幾年、被綁死在舊環境裡的系統。而過程中大部分的坑都不是「不會寫程式」:是環境限制、是工具本身的毛病、是要不要為了功能完整而去製造別人不想要的工作量。

有人問程式碼的話,這套跑在院內,不會開源。

更新時間: