2026年1月25日 星期日

FHIR 應用 : 「病人為主時序圖」

 

FHIR 應用 : 「病人為主時序圖」

Image


從兩週完成的「病人為主時序圖」談起

——為什麼採用 FHIR Server,醫療應用可以變得這麼快、這麼有價值

這篇文章,想用一個非常務實、已經完成的實例,來回答許多醫院人員與第一次接觸 FHIR 的朋友常問的問題:

「FHIR 到底能幫醫院做什麼?」 「為什麼以前這類系統很難做,現在卻可以在兩週內完成?」 「FHIR 與 SMART on FHIR 的真正價值在哪?」

以下內容,將以耀瑄科技新創小組實際開發的 「以病人為主的看診紀錄時序圖應用」 為核心案例,從臨床、資訊、管理與系統設計多個角度,帶你一步一步理解。


一、這是一個什麼樣的系統?

這是一套 以病人為核心(Patient-centric) 的醫療資料視覺化應用系統,主要功能包括:

  • 以「單一病人」為主軸
  • 將該病人跨年度、跨次就診的紀錄
  • 依照**時間順序(Timeline)**整合呈現
  • 一眼看懂「什麼時候、發生了什麼醫療事件」

使用者在畫面上可以看到什麼?

  1. 病人基本資料

    • 姓名、年齡、性別、身高、體重
    • 病歷號、身分識別資訊
  2. 年度/月份時間軸

    • 例如:1991 年 2 月、3 月有哪些就診
  3. 每一次看診的摘要

    • 門診 / 急診 / 住院
    • 診斷重點(如:流產、乳癌)
  4. 診斷內容時間表

    • 不需要打開多個系統
    • 不需要翻閱厚重病歷

👉 重點是:這一切資料都來自 FHIR Server,而不是客製化 HIS API。


二、資料從哪裡來?為什麼可以這麼快?

這個系統的資料來源是:

HAPI FHIR 公開測試 Server(R4) https://hapi.fhir.org/baseR4

這是一個完全符合 HL7 FHIR R4 規範的免費 FHIR Server,提供標準 RESTful API。

系統實際使用到的 FHIR Resource(舉例)

  • Patient:病人基本資料

  • Encounter:每一次就診(門診/住院/急診)

  • Condition:診斷、問題列表

  • Observation:身高、體重、檢驗數值

  • (未來可擴充)

    • MedicationRequest
    • Procedure
    • DiagnosticReport

為什麼「兩個星期」就能完成?

因為:

  • FHIR 已經幫我們把資料結構定義好了
  • 不用跟每一家醫院重新討論資料格式
  • 不用寫大量 ETL 或轉檔程式
  • 只要懂 FHIR Resource 與查詢方式,就能開發

👉 對工程師來說,這不是「寫醫療系統」,而是「使用一個標準 API」。


三、以前為什麼這樣的系統「做不到」或「很困難」?

這一段,對醫院資訊人員與管理層特別重要。

1️⃣ 資料被鎖在 HIS 裡(資料孤島)

傳統狀況:

  • 每家 HIS 廠商資料結構不同
  • 同樣是「診斷」,在不同系統欄位完全不一樣
  • 想跨系統整合 → 幾乎等於重做一套系統

👉 沒有共同語言,系統就無法快速開發。


2️⃣ 沒有「病人為主」的資料模型

過去系統設計大多是:

  • 以「表單」為中心
  • 以「科別系統」為中心
  • 以「申報流程」為中心

結果是:

  • 一個病人的資料散落在 N 個系統
  • 很難用「時間軸」方式整合呈現

👉 FHIR 天生就是以病人為核心設計。


3️⃣ 每做一個新應用,就要重新串接一次

以往常見情境:

  • 想做一個新視覺化 → 再寫一次 API
  • 換一家醫院 → 再重寫一次
  • 系統維護成本極高

👉 FHIR 把「資料取得」變成可重複使用的基礎建設。


四、採用 FHIR Server 的真正好處是什麼?

✅ 對醫院臨床人員

  • 快速看到病人完整歷程
  • 降低跨科、跨年度資訊落差
  • 提升臨床決策效率

✅ 對醫院管理層

  • 系統開發週期大幅縮短
  • 新應用可以「小步快跑、快速驗證」
  • 降低長期 IT 維運與整合成本

✅ 對資訊單位(HIS / IT)

  • 不必一次汰換現有 HIS
  • 可以用 FHIR Gateway / 中介層 漸進導入
  • 降低與多家廠商整合的複雜度

✅ 對未來(SMART on FHIR)

  • 可以直接支援第三方 App
  • 符合國際趨勢與台灣政策方向
  • 為 AI、研究、數據中台鋪路

五、這樣的系統,只是開始而已

「病人時序圖」並不是終點,而是FHIR 應用的第一個高價值入口

在這個基礎上,還可以延伸:

  • 個管師專用追蹤視圖
  • 慢性病照護時間線
  • 腫瘤治療療程總覽
  • AI 風險評估結果疊加
  • SMART on FHIR App 市集應用

👉 一旦資料標準化,創意與價值才會真正爆發。


六、耀瑄科技新創小組在 FHIR & SMART on FHIR 的研發成果

耀瑄科技新創小組,近年持續投入:

  • FHIR Server 架構設計
  • FHIR Gateway 與既有 HIS 整合
  • 病人為主的時序視覺化應用
  • SMART on FHIR App 實作
  • 臨床研究、AI 與資料中台整合

這個「兩週完成的系統」不是偶然,而是:

長期累積標準理解 × 實務導向開發 × 醫療場域經驗


七、給第一次接觸 FHIR 的你一句話總結

FHIR 不是要你立刻重建所有系統 而是讓你「終於可以開始做以前想做、卻做不到的事」

如果你是:

  • 醫師
  • 個管師
  • 醫院資訊人員
  • 管理者
  • 或對醫療資訊未來有興趣的人

那麼,FHIR + SMART on FHIR,會是未來 5–10 年醫療系統演進的關鍵基礎。





2026年1月15日 星期四

FHIR 09 說明 Standalone 建立 ExchangeToken 新頁面的程式碼做法與實測過程

FHIR 09 說明 Standalone 建立 ExchangeToken 新頁面的程式碼做法與實測過程

在上一篇文章中 FHIR 08 在 Standalone 建立新頁面來接收授權碼進而取得存取權杖並呼叫 FHIR API ,已經完成了相關 Exchange Token 的程式碼,透過這些新增的程式碼與頁面,透過準備新得到的存取權杖 Access Token 成功地呼叫 FHIR API 來讀取病患資料。

首先,先來執行這個專案,看看執行成果,接著,再來說明這些程式碼的做法與實測過程。

執行結果說明

依照底下步驟操作,完成使用 Smart On FHIR 沙盒環境來進行 EHR Launch 的授權碼取得流程:

  • 開啟並且執行 [SmartStandalone1]
  • 在瀏覽器的位置列中,輸入 https://localhost:7108/launch
  • 在 [Launch] 端點頁面中,將會顯示出文字 [請稍後,正在初始化中]
  • 接著,系統會自動將我們的瀏覽器,重新導向到授權伺服器
  • 現在,將會進入到 FHIR 的授權伺服器,要取得授權碼,當取得了授權碼之後,就會使用 redirect_uri 參數,重新導向回到我們開發的頁面端點 https://localhost:7108/ExchangeToken ,並且在網址列中帶入授權碼參數 code
  • 在網頁上,將會看到下面的畫面截圖,準備進入到身分驗證階段
  • 在這裡使用預設的帳號與密碼,點選 [Login] 按鈕
  • 現在的畫面將會切換到選擇病患的畫面
  • 點選名稱為 [Geoffrey Abbott] 病患項目,這個病人位於第二頁,準備進入到下一個階段
  • 這裡出現了 [Authorize App Launch] 對話窗,這裡需要你同意這個應用程式代表你,可以對 FHIR Server 做列出能力清單的操作。
  • 此時,現在可以點選 [Approve] 按鈕,來同意這個應用程式的授權要求
  • 當點選了 [Approve] 按鈕之後,系統將會重新導向回到我們的應用程式頁面端點 https://localhost:7108/ExchangeToken ,並且在網址列中帶入授權碼參數 code 與 state 參數
  • 這時候,ExchangeToken 頁面將會被載入,並且開始執行頁面程式碼
  • 之後,就會在螢幕上看到下面截圖畫面,這裡出現了從授權伺服器回傳的授權碼內容,這裡列出了 code & state 這兩個參數
  • 當使用 code 這個 OAuth2 授權碼來交換存取權杖,將會得到底下的內容
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6Im9wZW5pZCBmaGlyVXNlciBwcm9maWxlIGxhdW5jaC9wYXRpZW50IHBhdGllbnQvKi5yZWFkIHBhdGllbnQvRW5jb3VudGVyLnJlYWQgcGF0aWVudC9NZWRpY2F0aW9uUmVxdWVzdC5yZWFkIHBhdGllbnQvU2VydmljZVJlcXVlc3QucmVhZCIsImNvbnRleHQiOnsibmVlZF9wYXRpZW50X2Jhbm5lciI6dHJ1ZSwic21hcnRfc3R5bGVfdXJsIjoiaHR0cHM6Ly90aGFzLm1vaHcuZ292LnR3L3NtYXJ0LXN0eWxlLmpzb24iLCJwYXRpZW50IjoiMmNkYTVhYWQtZTQwOS00MDcwLTlhMTUtZTFjMzVjNDZlZDVhIn0sImNsaWVudF9pZCI6InNtYXJ0LWFwcCIsImZoaXJVc2VyIjoiUHJhY3RpdGlvbmVyLzE0NzUzMyIsImlhdCI6MTc2ODQ2NDg5NywiZXhwIjoxNzY4NDY4NDk3fQ.haFOCN0Bx7hLyxmrvf-6_4MEDGnFBf8iATmV9TfX8NU","token_type":"Bearer","expires_in":3600,"scope":"openid fhirUser profile launch/patient patient/*.read patient/Encounter.read patient/MedicationRequest.read patient/ServiceRequest.read","id_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9maWxlIjoiUHJhY3RpdGlvbmVyLzE0NzUzMyIsImZoaXJVc2VyIjoiUHJhY3RpdGlvbmVyLzE0NzUzMyIsImF1ZCI6InNtYXJ0LWFwcCIsInN1YiI6IjFhN2M3NjFiN2MzM2NiNGRmNGVhMDcwZTMwZmRiZjUxMzUxNjM1OWZjYzQ5YzNhZDYzYjY0MmU0NTEwOTc2NDQiLCJpc3MiOiJodHRwczovL3RoYXMubW9ody5nb3YudHcvdi9yNC9zaW0vV3pJc0lpSXNJaUlzSWtGVlZFOGlMREFzTUN3d0xDSWlMQ0lpTENJaUxDSWlMQ0lpTENJaUxDSWlMREFzTVN3aUlsMC9maGlyIiwiaWF0IjoxNzY4NDY0ODk3LCJleHAiOjE3Njg0Njg0OTd9.NxDdzAzT8wz1rl_sFGTCKUAE8gR0_WoVIVtgPK8jpi30UI0SWLSblp9a3RNqZTefKUStScrF8bKqV_oPkPE1cNB_jgy_X6SM1YawBE2J0zv_ShObwDfxunOPYmOOl1yfQf-qQuNP96ywAuFe3yljbnbvBd_zTfy5_H2ERSOxE0vN4Q8irY5p02YiJhWLTl62Q3E_aS2GZ4Kn-IGGnvAgmOWJEluVdVQ35s3UP-L_h272H8lS8iFs-5FO7xR4_iltleHBRZyBGMLnaZ-8IU_zPCxmcwBTLklLK1JE_f1HUFMNxa5itfb03tCV5kUC9wHTOdD_52h4jAhKrI1B1hSwdA","need_patient_banner":true,"smart_style_url":"https://thas.mohw.gov.tw/smart-style.json","patient":"2cda5aad-e409-4070-9a15-e1c35c46ed5a"}
  • 從上面的內容中,可以看到幾個重要的欄位值
    • access_token:這就是我們要用來存取 FHIR API 的存取權杖
    • id_token:這是一個 JWT 格式的身分識別權杖
    • patient:這就是我們這次所選擇的病患資源 ID
  • 透過上面提供的 Access Token 與 Patient ID ,接著,頁面程式碼會呼叫 FHIR API 來讀取病患資料
  • 讀取到的病患資料內容,包含病患名稱與出生日期
  • 最後,會取得該病人的身高與體重值,這裡是透過 FHIR 內的 Observation 資源來取得這些資料
  • 下面的截圖,顯示了從 FHIR 伺服器讀取到的病患資料內容 

剖析 Access Token 的輔助方法與模型

在這個 ExchangeToken 頁面的程式碼中,為了要能夠順利地交換取得存取權杖 Access Token 與呼叫 FHIR API 來讀取病患資料,必須要先建立兩個輔助的程式碼檔案,分別是 SmartResponse 資料模型與 JwtHelper 輔助支援類別。

SmartResponse.cs

  • 當要透過授權碼來取得存取權杖時候,將會得到剛剛提到的 JSON 物件
  • 該 json 物件結構符合:SMART on FHIR + OpenID Connect Hybrid
  • 這裡取得的 access_token 經過解碼之後的內容如下,這個存取權杖的有效期先將會是1 小時
{
  "scope": "openid fhirUser profile launch/patient patient/*.read patient/Encounter.read patient/MedicationRequest.read patient/ServiceRequest.read",
  "context": {
    "need_patient_banner": true,
    "smart_style_url": "https://thas.mohw.gov.tw/smart-style.json",
    "patient": "2cda5aad-e409-4070-9a15-e1c35c46ed5a"
  },
  "client_id": "smart-app",
  "fhirUser": "Practitioner/147533",
  "iat": 1768464897,
  "exp": 1768468497
}
  • 這裡取得的 id_token 經過解碼之後的內容如下,是 RS256,內容是 OpenID Connect 身分聲明
{
  "profile": "Practitioner/147533",
  "fhirUser": "Practitioner/147533",
  "aud": "smart-app",
  "sub": "1a7c761b7c33cb4df4ea070e30fdbf513516359fcc49c3ad63b642e451097644",
  "iss": "https://thas.mohw.gov.tw/v/r4/sim/WzIsIiIsIiIsIkFVVE8iLDAsMCwwLCIiLCIiLCIiLCIiLCIiLCIiLCIiLDAsMSwiIl0/fhir",
  "iat": 1768464897,
  "exp": 1768468497
}
  • 因此,這裡需要一個 SmartResponse 資料模型來對應這些欄位。透過了反序列化程式碼 SmartResponse smartResponse = JsonSerializer.Deserialize<SmartResponse>(json); 便可以將上述的 JSON 內容,轉換成 SmartResponse 物件來使用。

  • 在 Smart On FHIR 文件內的 2.2.3.3 Launch context arrives with your access_token 文件中,有提到授權伺服器回傳的存取權杖 Access Token 的 JSON 格式內容,這個內容需要一個資料模型來對應這些欄位,因此,我們建立了 SmartResponse.cs 這個資料模型來對應這些欄位。

Launch context parameterExample valueMeaning
patient"123"String value with a patient id, indicating that the app was launched in the context of FHIR Patient 123. If the app has any patient-level scopes, they will be scoped to Patient 123.
encounter"123"String value with an encounter id, indicating that the app was launched in the context of FHIR Encounter 123.
fhirContext[{"reference": "Appointment/123"}]Array of objects referring to any resource type other than "Patient" or "Encounter". See details below.
need_patient_bannertrue or false (boolean)Boolean value indicating whether the app was launched in a UX context where a patient banner is required (when true) or may not be required (when false). An app receiving a value of false might not need to take up screen real estate displaying a patient banner.
intent"reconcile-medications"String value describing the intent of the application launch (see notes below).
smart_style_url"https://ehr/styles/smart_v1.json"String URL where the EHR’s style parameters can be retrieved (for apps that support styling).
tenant"2ddd6c3a-8e9a-44c6-a305-52111ad302a2"String conveying an opaque identifier for the healthcare organization that is launching the app. This parameter is intended primarily to support EHR Launch scenarios.

ExchangeToken.razor 頁面程式碼說明

  • 在這個頁面,使用了底下語法,來接收使用 Query String 參數傳遞過來的值
[SupplyParameterFromQuery(Name = "code")]
public string? Code { get; set; }
[SupplyParameterFromQuery(Name = "state")]
public string? State { get; set; }
  • 在 OnAfterRenderAsync 方法中,這裡會先檢查是否為第一次渲染頁面
  • 在第一次渲染事件發生的時候,將會執行底下的敘述
await SetAuthCodeAsync();

StateHasChanged();
await System.Threading.Tasks.Task.Delay(5000);

SmartResponse smartResponse = await GetAccessTokenAsync();
await GetPatientAsync(smartResponse);
heightAndWeight = await GetHeightAndWeightAsync(smartResponse);
StateHasChanged();
  • 在 Blazor 元件第一次渲染後,自動完成「授權碼處理 → 換取 token → 讀取病人資料 → 讀取病人身高與體重」這一連串動作:
  • await SetAuthCodeAsync()
    • 從 OAuthStateStoreService 載入對應 State 的設定物件,寫入目前的 CodeState,更新 SmartAppSettingService,並用 JwtHelper 解碼 AuthCode(當作 JWT)存到 AuthorizationCodeJson,方便頁面顯示。
  • GetAccessTokenAsync()
    • 建立 POST 請求到 TokenUrl,帶上 grant_type=authorization_codecoderedirect_urilaunch,呼叫 OAuth token endpoint。
    • 回傳 JSON 反序列化成 SmartResponse(裡面包含 AccessTokenPatientId 等)。
  • GetPatientAsync(smartResponse)
    • 透過 FhirClient 讀取 Patient/{smartResponse.PatientId},把結果存到 patient 欄位,並把 isReadPatient 設為 true
  • GetHeightAndWeightAsync(smartResponse)
    • 透過 FhirClient 搜尋 Observation,條件是 patient=smartResponse.PatientId 且 code 是身高或體重的 LOINC code,取得最新的一筆身高與體重資料。

GetAccessTokenAsync 方法說明:

  • 為了要使用授權碼來交換存取權杖 Access Token,這裡將會使用底下程式碼建立 Post 方法要用到的 Payload
Dictionary<string, string> requestValues = new Dictionary<string, string>()
    {
        { "grant_type", "authorization_code" },
        { "code", SmartAppSettingService.Data.AuthCode },
        { "redirect_uri", SmartAppSettingService.Data.RedirectUrl },
        { "launch", SmartAppSettingService.Data.Launch }
    };
  • 然後,建立 HttpRequestMessage 物件,並且設定好相關屬性值
  • 接著,透過 HttpClient 透過 https://thas.mohw.gov.tw/v/r4/auth/token 端點來發送 Post 請求,這個服務端點則是透過授權伺服器所提供的 Token 端點
  • 最後,取得回傳的 JSON 內容,並且反序列化成 SmartResponse 物件來使用

GetPatientAsync 方法說明:

  • 這個方法主要是使用取得的存取權杖 Access Token 來呼叫 FHIR API 來讀取病患資料
  • 首先,建立 HttpClient 物件,並且設定好 BaseAddress 屬性值
  • 接著,設定好 Authorization 標頭內容,這裡使用 Bearer Token 的方式來進行授權
  • 然後,建立 FhirClient 物件,並且將剛剛建立的 HttpClient 物件傳遞給 FhirClient 來使用
  • 最後,使用 FhirClient 物件來讀取 Patient 資源,並且將讀取到的病患資料存到 patient 欄位中,並且將 isReadPatient 屬性設為 true,代表已經成功讀取到病患資料
  • 這裡將會透過 await fhirClient.ReadAsync<Patient>($"Patient/{smartResponse.PatientId}"); 方法來做到這個需求,也就是說,想要能夠存取 FHIR Server 上的資源,將會使用 FhirClient 物件來進行相關的操作,透過轉換成為 FHIR API,便可以很方便地來進行相關的存取動作
  • 這樣一來,頁面便可以顯示出病患的相關資料內容

GetHeightAndWeightAsync 方法說明:

下面是針對 GetHeightAndWeightAsync 方法,採用你給的 GetPatientAsync 說明風格所寫的說明內容:

GetHeightAndWeightAsync 方法說明:

  • 這個方法主要是使用取得的存取權杖 Access Token,透過 FHIR API 讀取指定病患的生命徵象(vital signs)觀測值,並從中擷取「身高」與「體重」資料
  • 首先,建立 HttpClient 物件,並設定好 BaseAddress 為 FHIR Server 的 URL
  • 接著,設定好 Authorization 標頭內容,這裡同樣是使用 Bearer Token 的方式來進行授權,Token 來源為 smartResponse.AccessToken
  • 然後,建立 FhirClientSettings,並將 PreferredFormat 設定為 ResourceFormat.Json,代表後續與 FHIR Server 溝通時,預期使用 JSON 格式
  • 之後,建立 FhirClient 物件,並將剛剛建立的 HttpClient 物件與 FHIR Server 的 URL 傳入,之後便可以透過這個 FhirClient 物件來呼叫 FHIR API
  • 接下來,建立一個 SearchParams 查詢條件:
    • 使用 patient={smartResponse.PatientId} 限定只查詢該病患的資料
    • 使用 category=vital-signs 限定只查詢屬於「生命徵象」類別的 Observation
    • 使用 LimitTo(50) 限制最多回傳 50 筆資料
  • 使用 await fhirClient.SearchAsync<Observation>(searchParams); 來呼叫 FHIR API,取得一個 Bundle,其中包含多筆 Observation 資源
  • 接著,逐筆走訪 bundle.Entry,針對每一筆 Observation 做以下處理:
    • 先確認 entry.Resource 真的是 Observation 型別
    • 取出 Observation.Code.Coding 中第一筆 coding 的 Code 欄位,把它視為 LOINC 代碼
    • 僅處理 Value 為 Quantity 型別的 Observation,並從 Quantity.Value 取得實際數值,從 Quantity.Unit 或 Quantity.Code 取得單位
    • 當 LOINC 代碼是 8302-2 時,代表身高(Body height),將其數值與單位存到 heightValue 與 heightUnit
    • 當 LOINC 代碼是 29463-7 時,代表體重(Body weight),將其數值與單位存到 weightValue 與 weightUnit
  • 這裡會將搜尋結果中最後一筆符合條件的 Observation 視為最終的身高或體重值(若有多筆同 code 的 Observation,後面的會覆蓋前面的變數值)
  • 最後,將取得到的身高與體重數值與單位,轉成字串,包裝成一個 VitalSignsResult 物件並回傳
  • 在 Blazor 頁面中,便可以利用這個欄位來顯示病患的身高與體重資訊

結論

透過這個 ExchangeToken 頁面的程式碼,成功地完成了使用授權碼來交換存取權杖 Access Token,並且使用取得的存取權杖來呼叫 FHIR API 來讀取病患資料的需求,這樣一來,便可以順利地完成 Standalone Launch 的整個流程。

若要取得這篇文章的程式碼,可以透過 https://github.com/vulcanlee/CSharp2025/tree/main/SmartStandalone1 取得