iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

本文同步發表於個人部落格:跨伺服器病歷整合


火線超人做完單一醫院的查詢之後,下一個問題很自然就浮出來了。病人在兩家醫院都看診,能不能一次看到全部?

聽起來只是把兩次查詢的結果接起來。真的動手才發現,難的不是接起來,是接起來之後怎麼讓人看得懂。

先講一個刻意不做的東西

跨院整合最容易被想成一個身分比對問題。兩家醫院各有一份病歷,得先確認這兩份是同一個人,才談得上合併。

火線超人沒有做這件事。

不是做不出來,是這條路的成本和風險都不對。真實世界的跨院比對要先處理一整堆髒資料。姓名的異體字、有沒有身分證號、出生日期填錯、同名同姓,每一種都得有自己的規則。比對錯了的後果是把別人的病歷顯示給你看,這在醫療情境裡是不能出的錯。

火線超人的做法是把這個問題還給病人:每一家你自己去授權一次。

病人在 A 醫院授權,系統就拿到一組 A 的 token 和一個 A 的 patient id。在 B 醫院再授權一次,拿到 B 的。這兩組 token 和 id 各自成立。系統從頭到尾不需要判斷這兩個 patient 是不是同一個人。

授權這個動作本身就是身分證明。病人能在 A 醫院的登入頁通過驗證,那他就是 A 醫院認定的那個人。這比自己寫一套比對規則可靠得多。

代價是病人要多授權幾次。我認為這個代價值得。

兩條路的對照圖,米色底,標題「跨院整合的兩條路:比對身分與各自授權」,副標「左邊是常見做法,右邊是火線超人實際採用的做法」。最上方一條深藍色橫帶,左端白色粗體寫「同一個病人」,右邊接淺藍灰小字「兩家醫院各有一份病歷」,橫帶最右側是兩個半透明白框標籤,左邊寫 A 醫院的病歷,右邊寫 B 醫院的病歷。橫帶底下垂下兩條直線,左邊那條是細的灰線,往下接到左卡的上緣;右邊那條是較粗的深藍線,往下接到右卡的上緣。左卡白底、左緣一條灰色直條,卡內第一行灰色小字寫「路線一」,第二行灰色粗體寫「比對出誰是同一個人」,再往下一行小字寫「要先處理的髒資料」,底下是二乘二排列的四個淺灰藍底標籤,左上是姓名異體字,右上是有沒有身分證號,左下是出生日期填錯,右下是同名同姓。四個標籤下方一個深紅色圓形叉號,右邊接紅字「比錯就把別人的病歷顯示給你看」。右卡比左卡大,白底、左緣一條深藍直條並帶陰影,卡內第一行灰色小字寫「路線二」,第二行深藍色粗體寫「每家各自授權一次」。底下兩列,每列左邊是一個深藍底白字的方塊,第一列寫「在 A 醫院授權」,右邊一個灰色小三角,再往右是兩個等寬字的淺色標籤,分別寫 A 的 token 與 A 的 patient id;第二列結構相同,方塊寫「在 B 醫院授權」,兩個標籤分別寫 B 的 token 與 B 的 patient id。兩列下方一行灰字寫「兩組東西各自成立,授權本身就是身分證明」。右卡最底是一條深藍色橫條,左端一個綠色圓形勾號,右邊白字寫「不判斷這兩個 patient 是不是同一人」。整張圖左下角一行灰色小字寫「代價:病人要多授權幾次」。

一台一條線,各自逾時

決定要查哪些之後,下一個問題是怎麼查。

火線超人是在 LINE 上跑的,這帶來一個硬性限制。LINE 的 reply token 只能用一次而且有時效。整個回覆必須在數秒內完成。

如果循序查詢,最壞情況是每台等到逾時上限,N 台就是 N 倍。三家醫院就超時了。

所以是並行。火線超人那邊是每台 server 開一條執行緒,也就是每台各跑各的,誰也不等誰。主程式在外面設一個總的等待時間。單台的逾時上限訂在 5 秒,寫成一個可以從外面改的常數,測試才調得短。

跟著做那一節沒有 reply token 的時間壓力,範例是一台一台查完再合併。瀏覽器要並行的話用 Promise.all,不是執行緒。

一台出事不能拖垮其他台。 每台的例外必須在它自己的查詢裡被接住,轉成那一台的狀態,不能往外丟。

狀態分三種:

狀態 意思
timeout 超過 5 秒還沒回來
error 回來了但是錯的
reauth_required token 過期,需要重新授權

第三種值得多講一句。token 過期的那一家不查,直接列成需要重新授權。

狀態對照圖,米色底,標題「單家出事的三種狀態:使用者實際看到什麼」,副標「其中 token 過期那一種,跳過與明確標示的差別最大」。最上方一條深藍色橫帶,左端白色粗體寫「一台沒有正常回來」,右邊接淺藍灰小字「例外在它自己的查詢裡接住,轉成那一台的狀態」,橫帶最右側一個半透明白框標籤寫「不往外丟」。橫帶底下是左右兩張卡片。左卡白底、左緣一條灰色直條,卡內第一行灰色小字寫「一台的結果只會是這三種」,底下三組,每組上行是一個等寬字的圓角標籤、下行是灰色小字說明。第一組標籤寫 timeout,說明寫「超過 5 秒還沒回來」。第二組標籤寫 error,說明寫「回來了但是錯的」。第三組標籤寫 reauth_required,是珊瑚色字加珊瑚色外框與淺珊瑚底,說明寫「token 過期,需要重新授權」。從左卡右緣、第三組那一列的高度,一條珊瑚色細線往右走一小段,再以圓角往上轉彎,走到右卡上緣的高度後再往右接進右卡。右卡比左卡大,白底、左緣一條深藍直條並帶陰影,卡內第一行珊瑚色小字寫「第三種的兩種做法」。右卡裡上下兩個區塊。上區塊淺灰底、灰色虛線外框,第一行灰色小字寫「一開始想的」,第二行灰色粗體寫「只查有效的,過期的跳過」,第三行一個深紅色圓形叉號接紅字「時間軸少一家,以為那家沒資料」。下區塊淺藍底、淺藍色實線外框,第一行灰色小字寫「改成」,第二行深藍色粗體寫「明確標成 reauth_required」,底下一條深藍色橫條,左端一個綠色圓形勾號,右邊白字寫「那一家標著要重新授權,看得到」。圖最底下一行灰色小字寫「5 秒是設計上訂的逾時上限,寫成一個可以從外面改的常數,測試才調得短」。

不聲不響地少給資料,比報錯更糟。

排序規則要能重現

合併之後怎麼排,這件事比想像中重要。

火線超人的規則是:先按資料類型分組,組內跨院合併之後依紀錄日期由新到舊。時間完全一樣的兩筆怎麼辦?用來源的代號字母序當次要排序鍵。

為什麼要訂一個次要鍵?因為沒有它,時間戳完全相同的那幾筆每次跑出來的順序都可能不一樣。順序不穩定,測試就寫不了,使用者也會覺得畫面在跳。

然後是最關鍵的一條:每一筆都標來源。

標來源聽起來像裝飾,實際上不是。等一下的跟著做會用真實資料證明。

那個日期是哪一國的日期

上一節說「依紀錄日期由新到舊」。那個日期,是哪一國的日期?

這個問題不問清楚,時間軸會排錯,而且錯得莫名其妙。

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,不是一筆一筆穿插。

執行結果圖,淺灰綠底色,標題「合併後 402 筆,前 94 筆全部來自 B 醫院」,副標「兩家各自授權後跑一次合併,時間軸依紀錄日期由新到舊,日期以本地時區顯示」。畫面中央一個白色圓角面板,最上一列是三個灰色小圓點,右邊接一個淺灰底的網址列,等寬字寫 localhost:5179/index.html。下一列是淺灰底橫帶,左端等寬字寫 Console,右端灰字寫「兩家各讀一次,再合併」。橫帶底下三列輸出,每列左邊是粗體的來源、中間是等寬字、右邊是一個大字的筆數。第一列左邊寫 A 醫院,中間寫 2011-10-15 至 2020-12-05,右邊灰色大字 94 筆。第二列左邊寫 B 醫院,中間寫 2012-01-19 至 2021-03-11,右邊灰色大字 308 筆。第三列上方有一條分隔線,左邊是深藍粗體的合併後,中間寫無時間者 0 筆,右邊深藍色大字 402 筆。再往下一行灰字寫「合併時間軸的來源,左邊最新,右邊最舊」。底下是一條橫貫面板的長條,左段約佔四分之一是深藍色實心塊,塊上白色粗體寫「前 94 筆」;右段是灰藍與淺珊瑚交錯的斜紋,斜紋中央疊一個白色圓角標籤,寫「其餘的筆數,整條時間軸來源共換手 19 次」。兩段交界處有一條珊瑚色垂直細線。長條下方三組文字,左邊兩行灰字,上行寫最新 2021-03-11,下行寫到 2021-02-18 都是 B;中間偏左是珊瑚色兩行字,上行寫「第 95 筆才換手」,下行寫 A 醫院 2020-12-05;右邊兩行灰字靠右對齊,上行寫最舊 2011-10-15,下行寫 A 醫院。面板最底一列由左而右三個淺灰標籤,分別是相異日期 24、來源換手 19 次、同一天同時有兩家的日期 0;同一列最右邊是圖例,深藍色方塊代表 B 醫院,斜紋方塊代表兩家交錯。圖最底下一行淺灰小字寫「斜紋那一段代表兩家交錯出現,區塊界線沒有逐筆記錄,所以不照比例畫。交錯以就診日成群發生,24 個日期裡沒有一天同時有兩家」。

最後一件事,這次沒有測到但一定會遇到:合併需要兩把鑰匙同時有效。

B 醫院那組 scope 沒有要 offline_access,所以拿不到 refresh token。放久了 token 就過期,而且救不回來,只能重新授權。兩家給的鑰匙有效期不一樣,這是設計上必然的後果。前面講的 reauth_required 那個狀態,就是為了這件事存在的。

查不到東西的時候放寬一次

還有一個細節,是火線超人上線之後才補的。這一節跟下一節講的都是正式系統的設計,跟著做的範例沒有這些功能。

預設查最近三個月。有些人三個月內剛好沒看診,合併起來就是零筆,畫面一片空白。

使用者看到空白會有兩種猜測。一種是「我這段時間真的沒看醫生」,另一種是「這個功能壞了」。使用者分不出來是哪一種。

處理方式是自動放寬一次。合併後零筆、而且至少有一台是正常回應,就改用十二個月重查。卡片上會標明實際查的是幾個月。

三個條件缺一不可。全部逾時或全部需要重新授權時不放寬。 全部失敗不是沒資料,是根本沒查到,放寬只是再失敗一次。最多只放寬一次,不然就變成無止境地往回撈。要標明查的是幾個月,使用者才知道自己看到的是三個月還是一年。

有些事明講不做

設計文件裡有一欄叫 Non-Goals,寫的是明講不做的事。這一欄跟功能清單一樣重要。

不做跨院檢驗數值的單位換算。 兩家用不同單位報同一個檢驗,就原值呈現、各自標來源,不幫你換算。換算表本身就是一個會出錯的地方。

不做跨院資料去重。 同一個病人在兩家做了同樣的檢驗,兩筆都留著。你可能覺得重複,但這兩筆真的是兩次不同的檢查。

火線超人唯一做的跨院運算是趨勢箭頭。拿最新值跟前一次比,允許跨院比較。變化超過 2% 才顯示箭頭,否則顯示一個橫線。單筆或非數值一律顯示橫線。

這條線畫在哪裡是有邏輯的。呈現和排序可以跨院做,因為那只是重新排列既有的事實。改寫數值不行,因為那是在製造原本不存在的資料。

跟著做那一節的完整版本在 GitHub 上,資料夾是 day24-cross-server。想先看跑起來的樣子,可以直接開線上版

界線對照圖,米色底,標題「重新排列既有的事實可以,改寫數值不行」,副標「六件跨院運算,落在同一條界線的兩邊」。畫面正中央一條珊瑚色垂直虛線由上往下貫穿,虛線頂端有一個米色底的珊瑚色粗體標籤寫「界線」。虛線左邊最上方是一條深藍色橫條,白色粗體寫「跨院做」;虛線右邊同一高度是一條淺灰藍橫條,灰色粗體寫「跨院不做」。左欄橫條底下三張白色卡片,每張左緣一條深藍直條並帶淡陰影,卡內左邊一個深藍色圓形勾號,右邊上行是粗體標題、下行是灰色小字。第一張標題合併排序,小字寫兩家的紀錄依日期一起排。第二張標題來源標註,小字寫每一筆記下是哪一家來的。第三張標題趨勢箭頭,小字寫最新值跟前一次比,允許跨院。右欄橫條底下三張淺灰底、灰色虛線外框的卡片,卡內左邊一個灰色圓形叉號,右邊上行是灰色粗體標題、下行是更淺的灰色小字。第一張標題檢驗數值單位換算,小字寫換算表本身就會出錯。第二張標題跨院資料去重,小字寫那真的是兩次不同的檢查。第三張標題跨院身分比對,小字寫改由病人每家各自授權。圖最底下左右各一行,左邊一個深藍色小方塊接深藍色字「只是重新排列既有的事實」,右邊一個淺灰小方塊接灰字「會製造原本不存在的資料」。

小結

跨院整合有四個決定。一是不做身分比對,改讓病人每家各自授權。二是並行查詢加單家逾時。三是每一筆都標來源。四是明講哪些事不做。

來源標註不是裝飾。402 筆裡前 94 筆來自同一家,這個數字自己會說話。

明天把鏡頭拉遠一點。台灣的 FHIR 生態長什麼樣,我們自己定的那套規格在裡面站在哪個位置。


上一篇
Day23 - 當標準還沒寫到你要的
下一篇
Day25 - 台灣的 FHIR 生態
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言