本文同步發表於個人部落格:跨伺服器病歷整合
火線超人做完單一醫院的查詢之後,下一個問題很自然就浮出來了。病人在兩家醫院都看診,能不能一次看到全部?
聽起來只是把兩次查詢的結果接起來。真的動手才發現,難的不是接起來,是接起來之後怎麼讓人看得懂。
跨院整合最容易被想成一個身分比對問題。兩家醫院各有一份病歷,得先確認這兩份是同一個人,才談得上合併。
火線超人沒有做這件事。
不是做不出來,是這條路的成本和風險都不對。真實世界的跨院比對要先處理一整堆髒資料。姓名的異體字、有沒有身分證號、出生日期填錯、同名同姓,每一種都得有自己的規則。比對錯了的後果是把別人的病歷顯示給你看,這在醫療情境裡是不能出的錯。
火線超人的做法是把這個問題還給病人:每一家你自己去授權一次。
病人在 A 醫院授權,系統就拿到一組 A 的 token 和一個 A 的 patient id。在 B 醫院再授權一次,拿到 B 的。這兩組 token 和 id 各自成立。系統從頭到尾不需要判斷這兩個 patient 是不是同一個人。
授權這個動作本身就是身分證明。病人能在 A 醫院的登入頁通過驗證,那他就是 A 醫院認定的那個人。這比自己寫一套比對規則可靠得多。
代價是病人要多授權幾次。我認為這個代價值得。

決定要查哪些之後,下一個問題是怎麼查。
火線超人是在 LINE 上跑的,這帶來一個硬性限制。LINE 的 reply token 只能用一次而且有時效。整個回覆必須在數秒內完成。
如果循序查詢,最壞情況是每台等到逾時上限,N 台就是 N 倍。三家醫院就超時了。
所以是並行。火線超人那邊是每台 server 開一條執行緒,也就是每台各跑各的,誰也不等誰。主程式在外面設一個總的等待時間。單台的逾時上限訂在 5 秒,寫成一個可以從外面改的常數,測試才調得短。
跟著做那一節沒有 reply token 的時間壓力,範例是一台一台查完再合併。瀏覽器要並行的話用 Promise.all,不是執行緒。
一台出事不能拖垮其他台。 每台的例外必須在它自己的查詢裡被接住,轉成那一台的狀態,不能往外丟。
狀態分三種:
| 狀態 | 意思 |
|---|---|
timeout |
超過 5 秒還沒回來 |
error |
回來了但是錯的 |
reauth_required |
token 過期,需要重新授權 |
第三種值得多講一句。token 過期的那一家不查,直接列成需要重新授權。

不聲不響地少給資料,比報錯更糟。
合併之後怎麼排,這件事比想像中重要。
火線超人的規則是:先按資料類型分組,組內跨院合併之後依紀錄日期由新到舊。時間完全一樣的兩筆怎麼辦?用來源的代號字母序當次要排序鍵。
為什麼要訂一個次要鍵?因為沒有它,時間戳完全相同的那幾筆每次跑出來的順序都可能不一樣。順序不穩定,測試就寫不了,使用者也會覺得畫面在跳。
然後是最關鍵的一條:每一筆都標來源。
標來源聽起來像裝飾,實際上不是。等一下的跟著做會用真實資料證明。
上一節說「依紀錄日期由新到舊」。那個日期,是哪一國的日期?
這個問題不問清楚,時間軸會排錯,而且錯得莫名其妙。
effectiveDateTime 是一個帶時區的字串。伺服器多半存 UTC,各地時區都是拿它加減幾小時。UTC 有 Z 和 +00:00 兩種寫法,意思一樣。想拿日期,最順手的寫法是切前十個字:
const day = (observation) => observation.effectiveDateTime?.slice(0, 10)
這一行在台灣是錯的。
我在 sandbox 上量到一組數字。同一筆 Observation 的兩個時間欄位,切出來差一天:
effectiveDateTime 2026-08-23T18:46:48Z 切出 2026-08-23
meta.lastUpdated 2026-08-24T11:26:53.498-04:00 切出 2026-08-24
這兩個欄位本來就是兩回事。effectiveDateTime 是臨床上的有效時間,meta.lastUpdated 是這筆資源最後更新的時間。要看的不是兩者差多久,是它們用了不同的時區寫法。一個寫 Z,一個寫 -04:00,因為那台伺服器在美東。
Z 跟 -04:00 這一段叫時區偏移(offset)。day20 講分頁時也出現過 offset,那個指的是筆數的位移,跟這裡沒有關係。
一份資料裡混著兩種時區寫法是常態,不是異常。
台灣是 UTC+8。台灣時間凌晨到早上八點之間的紀錄,UTC 日期會是前一天。上面那筆的臨床時間就是:
2026-08-23T18:46:48Z 實際上是台灣時間 08-24 凌晨 2:46
病人記得自己是 24 號半夜量的,圖上標 23 號。他不會來跟你說「你的時區處理錯了」,他只會覺得這個 app 怪怪的。台灣時間 00:00 到 07:59 的紀錄最容易踩到這個 bug。
上面那個還只是顯示錯一天。跨院整合會踩到更難發現的一種:排序整個反過來。
等一下第四步的排序,直覺寫法是拿兩個字串直接比大小。兩家的時區偏移寫法只要不一樣,這個比較就會給出錯的答案:
A 醫院回 2026-08-23T01:00:00Z UTC 01:00
B 醫院回 2026-08-23T02:00:00+08:00 台灣 02:00,換算成 UTC 是前一天 18:00
字串比較看到 02:00:00+08:00 大於 01:00:00Z,把 B 那筆判成比較新。實際上 B 早了七個小時。
這次兩家的 effectiveDateTime 寫法一致,所以拿這份資料測不出這個問題。要排序用的那個欄位在兩家寫法不同才會現形,而那正是跨院的日常。
修法是不要拿字串比。轉成時間戳再比,new Date() 認得 Z 也認得 +08:00:
const at = (row) => new Date(row.when).getTime()
顯示也一樣,不要碰字串,轉成 Date 再取本地欄位:
function localDay(iso) {
const parsed = new Date(iso ?? '')
if (Number.isNaN(parsed.getTime())) return ''
const pad = (value) => String(value).padStart(2, '0')
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`
}
getFullYear() 這一組方法回的是瀏覽器本地時區的值。兩種寫法進去,同一個本地日期出來。
把時間切成日期當 key,除了時區還有第二個問題:同一天的第二筆會不見。
用日期當 key 去找資料,很容易寫成掃到第一筆就停:
labels.map((label) => list.find((one) => day(one) === label))
一次就診產生一整叢觀測,病人自己在家量血壓一天量好幾次也是常態。find 掃到第一筆就回傳,同一天後面那幾筆根本沒去看,存進去了也看不見。
先建索引再取值,同一天要留哪一筆是你決定的:
const byDay = new Map()
for (const one of list) byDay.set(day(one), one)
這樣寫一天還是只留一筆,但留的是最後一筆,不是 find 隨手撈到的第一筆。一天的每一筆都要留,就得把 Map 的值換成陣列。
決定要做之前,我比較過另外兩條路,兩個都被否決了。
第一個是另外寫一個直接打 FHIR API 的查詢層,專門給聚合用。否決理由是它會把檢驗代碼表、token 處理、錯誤分類這些東西全部複製一份。兩份程式碼做同一件事,改一邊忘了改另一邊只是時間問題。
第二個是改成背景工作,查完再用推播通知使用者。否決理由是它需要額外的架構,而且推播訊息在 LINE 上是有額度的。為了省幾秒的等待去燒額度,划不來。
留下來的方案就是在原本的請求裡並行查完。這個方案不漂亮,但不用多加一個背景服務。
起點是第三幕結束的那份專案,servers.js 跟兩家醫院的設定都已經在了。昨天那一節是純 curl,沒有東西要帶過來。
先講一件 sandbox 的限制:兩家的模擬病人本來就是兩個不同的人。 這一節示範的是合併與來源標註,不是身分比對。
要合併就要同時握有兩把 token。fhirclient 一次只記得一組授權狀態。那組狀態放在 client.state 裡,包含 token 和病人資訊。所以每次授權完成之後,要自己把它收起來。
第一步,把每次授權的結果收下來。 授權按鈕裡先記下這次要連哪一家:
const STATE_PREFIX = 'smart-app.state.'
const PENDING_KEY = 'smart-app.pending'
// 授權前先記下這次是哪一家,導回來才知道要存到哪個位置
sessionStorage.setItem(PENDING_KEY, key)
導回來之後,在 ready() 的 then 裡收下:
const key = sessionStorage.getItem(PENDING_KEY)
sessionStorage.removeItem(PENDING_KEY)
if (key) sessionStorage.setItem(STATE_PREFIX + key, JSON.stringify(client.state))
removeItem 那行不能省。少了它,下一次重新載入會拿到舊的 pending。結果是 client.state 被存到錯的位置。
收在 sessionStorage 不是 localStorage。關掉分頁就沒了,這跟 fhirclient 本來的預設一致。token 長期留在瀏覽器裡是另一個問題,day27 會談。
第二步,兩份都在了就各自重建 client,各查一次:
const read = async (key) => {
const state = JSON.parse(sessionStorage.getItem(STATE_PREFIX + key))
const client = FHIR.client(state)
return client.request(
`Observation?patient=${client.patient.id}&_count=100`,
{ pageLimit: 0, flat: true }
)
}
const observationsA = await read('a')
const observationsB = await read('b')
查詢那一行有個坑我踩過:
// 錯的寫法。token 裡有 patient context,不代表查詢會自動限定範圍
client.request('Observation?_count=50')
我第一次寫成這樣,結果它翻到第 850 筆還沒停。那次查詢在翻這台 sandbox 上所有病人的資料。pageLimit: 0 是跟到最後一頁,flat: true 是把 entry 攤平,兩個都是第三幕教過的。
第三步,貼來源標籤。這一步一定要在合併之前做,合併之後就分不出來了。
const tag = (resources, server, key) =>
resources.map((r) => ({
key,
label: server.label,
when: r.effectiveDateTime ?? r.effectivePeriod?.start ?? null,
text: r.code?.text ?? r.code?.coding?.[0]?.display ?? '(無名稱)',
}))
const taggedA = tag(observationsA, SERVERS.a, 'a')
const taggedB = tag(observationsB, SERVERS.b, 'b')
第四步,合併排序。 沒有時間的排最後,不要讓它們排在最新的那幾筆前面:
const all = [...taggedA, ...taggedB]
const dated = all.filter((row) => row.when)
const undated = all.filter((row) => !row.when)
// 轉成時間戳再比,不要拿兩個字串比大小。
// 兩家的時區偏移寫法只要不一樣,字串比較就會給出錯的順序。
const at = (row) => new Date(row.when).getTime()
dated.sort((x, y) => {
const dx = at(x)
const dy = at(y)
if (dx !== dy) return dy - dx
return x.key.localeCompare(y.key) // 時間完全相同時,用來源代號當次要鍵
})
const rows = [...dated, ...undated]
這兩家 sandbox 的時區偏移寫法一致,所以照字串排也會得到同一個答案。換成真的兩家醫院就不一定了,前面那一節說的就是這件事。
我這次跑出來的是 A 醫院 94 筆、B 醫院 308 筆,合併後 402 筆,全部有時間。
94 加 308 等於 402,這一步要真的算一次。兩家各自讀回來的陣列先各記一次長度,加起來跟合併後的筆數比,對不起來就有問題。少了通常是分頁沒跟到底,多了通常是查詢沒帶 patient。
想跟伺服器對答案的話要另外送一次 _summary=count。這個參數是叫伺服器只回筆數,不回資料本身。flat: true 已經把 Bundle 攤平了,total 那個欄位拿不到。這台是公開共用的 sandbox,total 跟你實際翻到的筆數不一定相等。真正要對得起來的是自己算的那三個數字。
現在把來源那一欄遮起來看一次。你會看到一條 402 筆的時間軸,從 2021 排到 2011,看起來很完整。
再把來源打開。前面 94 筆全部來自 B 醫院。
這就是資料量差三倍以上的後果。使用者滑到快四分之一,才會看到第一筆來自另一家的資料。沒有來源標註,他只會覺得「我的紀錄好像怪怪的」,但說不出哪裡怪。
還有兩個觀察值得記下來。
排序真的橫跨兩家。 換手是這樣數的:排好之後從頭掃一遍,這一筆跟上一筆不是同一家就算一次。402 筆裡這種情況出現 19 次,不是 A 全部排前面 B 全部排後面。如果只換一次,那就代表兩家的紀錄幾乎沒有交錯,排序這件事也就沒有示範的意義。
交錯是以就診日為單位成群發生的。 這 402 筆分佈在 24 個相異日期上,而且沒有任何一天同時有兩家的資料。一次就診會產生一整叢觀測。所以時間軸看起來是一塊 B、一塊 A、再一塊 B,不是一筆一筆穿插。

最後一件事,這次沒有測到但一定會遇到:合併需要兩把鑰匙同時有效。
B 醫院那組 scope 沒有要 offline_access,所以拿不到 refresh token。放久了 token 就過期,而且救不回來,只能重新授權。兩家給的鑰匙有效期不一樣,這是設計上必然的後果。前面講的 reauth_required 那個狀態,就是為了這件事存在的。
還有一個細節,是火線超人上線之後才補的。這一節跟下一節講的都是正式系統的設計,跟著做的範例沒有這些功能。
預設查最近三個月。有些人三個月內剛好沒看診,合併起來就是零筆,畫面一片空白。
使用者看到空白會有兩種猜測。一種是「我這段時間真的沒看醫生」,另一種是「這個功能壞了」。使用者分不出來是哪一種。
處理方式是自動放寬一次。合併後零筆、而且至少有一台是正常回應,就改用十二個月重查。卡片上會標明實際查的是幾個月。
三個條件缺一不可。全部逾時或全部需要重新授權時不放寬。 全部失敗不是沒資料,是根本沒查到,放寬只是再失敗一次。最多只放寬一次,不然就變成無止境地往回撈。要標明查的是幾個月,使用者才知道自己看到的是三個月還是一年。
設計文件裡有一欄叫 Non-Goals,寫的是明講不做的事。這一欄跟功能清單一樣重要。
不做跨院檢驗數值的單位換算。 兩家用不同單位報同一個檢驗,就原值呈現、各自標來源,不幫你換算。換算表本身就是一個會出錯的地方。
不做跨院資料去重。 同一個病人在兩家做了同樣的檢驗,兩筆都留著。你可能覺得重複,但這兩筆真的是兩次不同的檢查。
火線超人唯一做的跨院運算是趨勢箭頭。拿最新值跟前一次比,允許跨院比較。變化超過 2% 才顯示箭頭,否則顯示一個橫線。單筆或非數值一律顯示橫線。
這條線畫在哪裡是有邏輯的。呈現和排序可以跨院做,因為那只是重新排列既有的事實。改寫數值不行,因為那是在製造原本不存在的資料。
跟著做那一節的完整版本在 GitHub 上,資料夾是 day24-cross-server。想先看跑起來的樣子,可以直接開線上版。

跨院整合有四個決定。一是不做身分比對,改讓病人每家各自授權。二是並行查詢加單家逾時。三是每一筆都標來源。四是明講哪些事不做。
來源標註不是裝飾。402 筆裡前 94 筆來自同一家,這個數字自己會說話。
明天把鏡頭拉遠一點。台灣的 FHIR 生態長什麼樣,我們自己定的那套規格在裡面站在哪個位置。