上線檢查清單
火線超人本來跑在 DigitalOcean。後來要跟其他醫院介接,對方要求資料不能出境,整台搬回國內。
搬家不是把檔案複製過去就好。主機位址變了,網域要重新指過去,憑證要重簽。而 app 那邊有一份設定也必須跟著改,改的地方還不在自己的程式碼裡。
前面二十七天,你的 app 一直跑在 localhost。今天講把它推出去。最容易壞的那一條跟部署工具無關,而且同樣是你在本機重現不出來的那種。
你要推上去的是什麼
打開第四幕那個目錄,裡面是 index.html、app.js、merge.js、servers.js 四支檔案,加一個 vendor/。
沒有 package.json,沒有建置步驟,也沒有伺服器端程式。所以部署就是把這幾個檔案原樣放上去,放到一台能給你 HTTPS 的靜態伺服器。
這件事有兩面。好的一面是不會有建置失敗這種事,你在本機看到的就是使用者看到的。另一面是每個檔案都會被讀到,servers.js 也一樣,第三條講的就是這件事。
也因為沒有伺服器端程式,整排容器、資料庫與資料搬移的部署問題跟你無關。那些是火線超人那種後端服務才要處理的。
上線的順序
這五步不能換順序,換了會卡在中間某一步。
- 挑一家靜態託管,先把目錄推上去一次,拿到那個公開網址
- 把網址帶去授權伺服器那邊,註冊成 redirect URI
- 在瀏覽器打開那個網址,console 敲
window.isSecureContext,要回true - 跑一次完整授權,從按下登入到畫面出現病人資料
- 對照 sandbox 那次的結果,確認畫面上的資料一致
第一步跟第二步不能對調,你要先有網址才註冊得了。而第二步沒生效之前,第四步一定會失敗在授權那一側。
挑託管看兩件事。第一是有沒有自動幫你上 HTTPS。GitHub Pages、Cloudflare Pages、Netlify 都有。自己架的話憑證要自己處理。Caddy 會自動申請,nginx 配 Let's Encrypt。
第二件事只有醫療這一行才會遇到:主機放在哪個國家。開頭那次搬遷就是為了這個。
你只連 sandbox 的時候碰不到它。等你要接第一家真的醫院,它就是先決條件。而且是換機房等級,不是改一行設定。
第四步是整份清單最容易被跳過的一步。首頁打得開不代表 app 能用,因為授權那一段完全沒被碰到。
火線超人搬第二次主機時,驗收是逐項實測記在任務底下的。五個頁面與兩支 API 都回 200,webhook 由平台的真實事件驗證。而且明確區分了哪些錯誤是應用程式本來就有的,不是這次搬遷造成的。
你的驗收沒那麼多項,但形狀一樣。要跑到病人資料出現在畫面上才算過,不是看首頁。
下面五條就是每一步會壞在哪裡。
第一條:redirect URI 要重新註冊,而且要完全一致
你的 app 現在是這樣寫的:
FHIR.oauth2.authorize({
iss: server.fhirBaseUrl,
clientId: server.clientId,
scope: server.scope,
redirectUri: window.location.pathname,
})
window.location.pathname 在本機是 /。fhirclient 會用目前的 origin 把它補成 http://localhost:5173/。換到公開網域,補出來的就是 https://你的網域/。
授權伺服器那邊記著一份你註冊過的網址清單。導回的網址不在清單裡,它就不導,直接給你一個錯誤。
它在各家的設定畫面上不一定叫 redirect URI。OAuth 規範的參數名是 redirect_uri。很多平台的欄位標成 Callback URL,指的是同一個。
麻煩在於那是逐字比對。少一條斜線、協定寫成 http、多一個 www。這三個在你眼裡是同一個網址,在它眼裡是三個不同的字串。
還有一件事更容易漏掉。sandbox 跟正式環境通常是兩份清單,各自註冊,不會互相同步。這兩邊通常是兩個窗口,甚至兩套申請流程。

所以兩份清單這件事在 sandbox 上測不出來。要等到上線當天,使用者按下登入才會發現。
有一個順手的自保方式:不要在程式碼裡寫死完整網址。window.location.pathname 會跟著網域變,要改的只有伺服器上那兩份清單。
這一條為什麼排第一?因為它是那種程式碼完全正確、部署也成功,使用者按下去卻不會動的問題。錯誤訊息只有一句 Invalid redirect_uri parameter,day09 那次實測就是這句。它不會告訴你差在哪個字元。
第二條:沒有 HTTPS,你的 app 算不出 PKCE
這一條不是最佳實務,是硬性的技術限制。
day08 講過 PKCE。你的 app 送出授權請求之前要先算一個 code_challenge,用的是 SHA-256。
那個雜湊是瀏覽器的 Web Crypto 算的。而 crypto.subtle 只在安全情境下才存在,你會遇到的兩種是 HTTPS 或 localhost。
fhirclient 對這件事有明確的錯誤訊息,翻原始碼看得到:
Some of the required subtle crypto functionality is not available unless you run this app in secure context (using HTTPS or running locally).
所以順序是這樣。先有 HTTPS 才有 crypto.subtle,算得出 code_challenge 才送得出授權請求。不是上線之後有空再加 TLS。

day08 舉過一個很容易踩到的例子:為了用手機測而改開 http://192.168.x.x:5173。區網 IP 不是安全情境,crypto.subtle 就沒了。
這一條沒做的後果特別難查。使用者按下登入就停在原地,而你在本機永遠重現不出來。
第三條:secrets 不進版控
你的 app 沒有 secret 可以外流。
瀏覽器裡的檔案使用者全都打得開,密碼寫進去等於公開。所以 SMART 不發密碼給這種 app。day07 講過,它叫 public client。
授權伺服器改用 day08 那組 PKCE 的亂數。它證明來換 token 的就是發起授權的那個程式。那組亂數每次授權重新產生,換完就作廢。
打開 servers.js 看一眼。每台伺服器只記 FHIR base URL、clientId、scope,加一個顯示用的 label。clientId 本來就是公開的識別碼。
但你只要往前一步做後端,情況就變了。火線超人那邊有 client secret,它只存在加密的資料庫欄位裡。值從環境變數帶進去,解密金鑰不在版控裡。
驗收方式很具體:在版控歷史裡搜那個 secret 的值,必須零命中。
明文憑證進版控,一旦發生就收不乾淨。你刪得掉那一行,但舊的 commit 裡還有那一筆。而且補救不是改一行程式碼,是換掉那把憑證,還要改每一個用它連線的地方。
第四條:沒有監控,等於不知道自己掛了
火線超人那份部署文件建議了六項設定,從 CPU 告警到每日資料庫備份都有。
我去翻設定檔想確認哪幾項真的裝了。找得到痕跡的只有兩樣,而且兩樣都不在那份建議清單上。

一樣是日誌層級,另一樣是被實測逼出來的記憶體上限。這一段我照實寫,因為漂亮的版本對你沒有用。
那你那個靜態 app 呢?你沒有伺服器程序會掛掉,所以監控對你來說是另一回事。
你的 app 跑在使用者的瀏覽器裡,錯誤不會回到你這邊。第一條那個 redirect URI 註冊錯了,使用者按下登入就卡住。而你這邊什麼都收不到,唯一的管道是有人跑來跟你講。
這就是靜態 app 版本的監控缺口,而下一條講的是同一種安靜。
第五條:快取要有失效路徑
你的 app 裡有兩層快取。一層是你自己寫的,另一層是瀏覽器自己做的。
你寫的那一層
day22 把每台伺服器的 discovery 結果存了下來,存活時間 24 小時。唯一的失效方式是有人手動去清。
這個範例本身踩不到那個坑,day22 說過了。FHIR.oauth2.authorize() 自己會去抓一次 discovery,授權用的不是你快取的端點。
真正會踩到的是拿快取的端點去打 token 端點做 refresh。端點換掉之後,請求會打到舊位址而失敗。而且快取沒過期之前,你每次重試都會打到同一個舊位址。
所以這一條的檢查項不是有沒有裝監控,是有沒有路徑讓你知道快取過時了。
瀏覽器自己那一層
你寫的那一層在哪一行、存多久、怎麼清掉都查得到。瀏覽器那一層不一樣,它不在你的程式碼裡,你也沒設定過它。
我是在 day18 那個功能上撞到它的。把血壓寫回伺服器之後,我讓畫面重查一次再重畫趨勢圖。想法是查得回來才算真的存進去。結果存完那一筆,圖上就是不出現,要重整才會有。
第一時間我以為是寫入失敗。用 curl 直接讀那個 id,回 200,資源好好的躺在那裡。
第二個猜測是伺服器的搜尋索引晚幾秒才更新。實測下去也不對,寫完立刻查就查得到。
我還順手看了回應的標頭。沒有 Cache-Control,沒有 ETag,也沒有 Last-Modified。當下的結論是跟快取無關。
這個結論是反的,而且我是拿 curl 得到的。
真正的答案要在瀏覽器裡才看得到。同一筆 POST 之後,用兩種寫法各查一次:
client.request(q, …) 19 筆,看不到剛寫的那一筆
client.request({ url: q, cache: 'no-store' }) 20 筆,看得到
沒有那三個標頭,瀏覽器可以自己判斷這份答案還新不新鮮。我這次的瀏覽器就是這樣做的,直接把上一次的結果拿出來用。少了快取標頭不等於不快取。正好相反,沒有標頭就沒有任何指示叫瀏覽器回頭問。
修法是查生命徵象的時候明講不吃快取。client.request() 第一個參數改成物件才放得下 fetch 的選項:
client.request(
{ url: `Observation?patient=${client.patient.id}&code=${code}`, cache: 'no-store' },
{ pageLimit: 0, flat: true }
)
只有剛寫完要立刻重讀的查詢需要這樣。病況跟用藥不會在寫入之後馬上重查,讓它們繼續吃快取沒關係。
這一條值得加進你自己的清單。寫回去之後立刻重讀的那條路徑,中間有沒有一層瀏覽器的快取。
還有一個更通用的教訓。curl 看得到標頭,看不到瀏覽器拿那些標頭做了什麼決定。協定層的證據不能拿來回答執行環境的問題,這兩件事我那天混在一起了。
跟著做:拿這張表掃你自己的 app
打開你第四幕做出來的專案,逐條回答。每一條都在你自己的檔案裡查得到。
| 檢查項 | 怎麼查 | 你大概會查到 |
|---|---|---|
| redirect URI 寫死了嗎 | 搜 redirectUri,看它是不是 window.location.pathname |
是,所以換網域時它會自己跟著變,但授權伺服器那邊要重註冊 |
| 你的 app 有 client secret 嗎 | 打開 servers.js 看有沒有 clientSecret |
沒有,因為它是 public client |
| 哪個欄位不該進版控 | 看 servers.js 那四個欄位,哪一個算敏感 |
四個都不算,clientId 本來就是公開的 |
| 沒有 HTTPS 會壞在哪 | 在瀏覽器 console 打 window.isSecureContext |
本機回 true,因為 localhost 被放行 |
| 快取過時你會知道嗎 | 搜 TTL_MS,看 24 小時到期前有沒有其他失效路徑 |
只有那顆「清掉全部授權」的按鈕會順手清掉它 |
最後一列要補的話有兩條路。一是授權失敗時自動清掉那台的快取再重試一次,二是把存活時間縮短。兩條都會多送幾次請求,你是拿速度換早一點發現端點變了。
小結
五條,後半句是不做會怎樣:
- redirect URI 要重註冊而且完全一致,不然使用者會卡在授權那一側,只看到一句 redirect_uri 不對
- 沒有 HTTPS 你的 app 就算不出 PKCE,授權連第一步都送不出去
- secrets 不進版控,寫進去一次舊的 commit 就留著,而且憑證要整把換掉
- 沒有監控就沒有系統主動通知你,只能等使用者跑來講
- 快取要有失效路徑,包括瀏覽器那一層你沒寫的
明天回頭看整個專案。哪些決策後來被自己推翻了,哪些九個月沒動過。