07 · 後端實作指南:不用 HAPI,怎麼做出 FHIR 模式的 API
這份文件寫給後端工程師。 前面 01–06 說的是「介面長什麼樣、為什麼這樣設計」; 這一份說的是「你們手上那套自建資料表,要怎麼長出那個介面」。
本文件每一節都有對應的可執行程式:
../fhir-server(Express + SQLite 參考實作)。 讀到某一段想看實際長怎樣,就去翻那邊對應的檔案,或直接npm start打開演練台點點看。前提:伺服器不使用 HAPI FHIR,資料庫是依
G1_物件目錄(vietnamEMR repo:用戶需求/G_資料模型/G1_物件目錄.md) 自建的關聯式資料表。這不影響 API 的形狀——FHIR 在本專案是介面契約,不是儲存格式。
7.1 一句話架構
Vue 前端 ──FHIR R4 JSON──> API 層 ──SQL──> 自建資料表(G1 的 71 個物件)
│
└──事件──> 整合層 ──> HIS(D1 合約)
API 層做三件事,只做這三件事:
| 職責 | 內容 |
|---|---|
| 投影 | 把自建表的列組成 FHIR 資源;把進來的 FHIR 資源拆回自建表 |
| 驗證 | 擋掉違反 04 BR-* 的請求,回 05 的 OperationOutcome |
| 記錄 | 每次寫入產生新版本、AuditEvent,必要時 Provenance |
三件不要做的事
- 不要把整份 FHIR JSON 塞進一個
text欄位就當作完成。 搜尋、統計、趨勢圖、I/O 平衡全部會卡死。 JSON 快照要存(見 7.4),但它是版本留存用的,不是主要查詢路徑。 - 不要為了像 FHIR 而把自建表改名成 FHIR 資源名。 表是你們的,欄位語意才是契約。
medication_request這張表不必改叫MedicationRequest,但它的route欄位必須能組出dosageInstruction[0].route,且帶得出coding.system+coding.code。 - 不要把 HAPI 才有的功能當成契約。 本規格刻意不要求 chained search、
_filter、 GraphQL、Subscription、$everything。沒有 HAPI 也做得完,就是因為這些一開始就不在範圍內。
7.2 先分清楚兩種表,做法不一樣
H12_API共用規範 §1(vietnamEMR repo:用戶需求/H_產品規格/)的三層分離直接決定後端要寫多少程式:
| 類別 | 物件 | API | 寫入從哪來 |
|---|---|---|---|
| HIS 唯讀鏡像(13 個) | patient、patient_identifier、encounter、encounter_location、coverage、diagnostic_report、specimen、microbiology_isolate、antimicrobial_susceptibility、medication_dispense、practitioner、organization、location |
只開 GET |
整合層收 D1 事件寫入,臨床端一律唯讀 |
| EMR 自有 | medication_request、medication_administration、observation、composition、task、signature、service_request… |
完整 CRUD(無 delete) | 臨床端寫入 |
唯讀鏡像不需要版本控制、不需要 If-Match、不需要業務規則驗證——它們只要能被投影成 FHIR 讀出來。
真正要花工的是 EMR 自有那一組。先把工作量這樣切開,排程會準很多。
臨床帳號寫唯讀資源一律回
422 READ-ONLY-SOURCE,不是 403。 理由見 01 §1.2.1:這不是權限不足,是這筆資料在本系統沒有寫入路徑。
7.3 儲存策略:三種選項,本專案選 C
| 選項 | 做法 | 優點 | 代價 |
|---|---|---|---|
| A · 純關聯式 | 只有自建表,每次讀取即時組裝 FHIR | 查詢快、統計容易 | vread/_history/帶版本 Reference 做不出來 |
| B · 純文件 | 一張 resource(id, type, json) 表 |
實作最快 | 搜尋、趨勢、I/O 全要掃 JSON;等於放棄 G1 的建模成果 |
| C · 混合(本專案) | 自建表是現況的權威;另加版本表存每次寫入的 FHIR JSON 快照 | 兩邊的優點都拿到 | 每次寫入多一次 insert |
選 C 的理由不是效能,是 01 §1.4「不覆寫」:
- 病程引用檢驗值必須引用當時看到的那一版(
Observation/x/_history/1)。 - 給藥執行必須指向開立當下那一版醫囑(
MedicationRequest/x/_history/3)。 - 病歷更正是新版本,舊版永遠讀得回來。
這三件事只要少一件,稽核就辯護不了。而它們共同需要的,就是「任一資源的任一舊版本,原樣讀得出來」—— 最省事的做法就是寫入時順手存一份 JSON 快照。
7.4 三張基礎設施表
以下用 PostgreSQL 方言。SQL Server/MySQL 請自行換型別(jsonb→nvarchar(max)/json,
gen_random_uuid()→newid()/uuid()),語意不變。
-- 1) 邏輯識別碼 ↔ 自建表主鍵的對照
-- FHIR 的 URL 用 logical_id,資料還是放在各自的業務表
CREATE TABLE fhir_resource (
resource_type varchar(64) NOT NULL, -- 'MedicationRequest'
logical_id varchar(64) NOT NULL, -- 出現在 URL 的 id
source_table varchar(64) NOT NULL, -- 'medication_request'
source_pk varchar(64) NOT NULL, -- med_request_id
current_version integer NOT NULL DEFAULT 1,
is_deleted boolean NOT NULL DEFAULT false, -- 永遠 false,保留欄位不使用
created_at timestamptz NOT NULL DEFAULT now(),
last_updated timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (resource_type, logical_id),
UNIQUE (source_table, source_pk)
);
-- 2) 版本快照(append-only,永不 UPDATE、永不 DELETE)
CREATE TABLE fhir_resource_version (
resource_type varchar(64) NOT NULL,
logical_id varchar(64) NOT NULL,
version_id integer NOT NULL,
content jsonb NOT NULL, -- 該版本的完整 FHIR JSON
recorded_at timestamptz NOT NULL DEFAULT now(),
recorded_by varchar(64) NOT NULL, -- practitioner_id,稽核用
PRIMARY KEY (resource_type, logical_id, version_id)
);
-- 3) HIS 業務識別碼對照(冪等與反查都靠它)
CREATE TABLE his_id_map (
his_system varchar(64) NOT NULL, -- 'encounterNumber' | 'prescriptionNumber' | 'orderNumber'
his_key varchar(128) NOT NULL,
resource_type varchar(64) NOT NULL,
logical_id varchar(64) NOT NULL,
PRIMARY KEY (his_system, his_key)
);
三點務必照做:
fhir_resource_version只給 INSERT 權限。 用 DB 角色擋掉 UPDATE/DELETE,不要只靠程式自律。 這是「不覆寫」原則唯一不會被繞過的實作方式。logical_id發出去就不可變、不可重用。 即使資料被標成entered-in-error也一樣。current_version就是ETag的來源,不要另外算。
7.5 id 與 identifier 是兩件事
最常見的錯誤是把 HIS 的處方號直接拿來當 FHIR 的 id。
| FHIR 元素 | 誰發 | 可變 | 例 | |
|---|---|---|---|---|
| 邏輯識別碼 | id(在 URL 裡) |
你們的 API | 否 | mr-efferalgan-0831 |
| 業務識別碼 | identifier[] |
HIS 或院方流程 | 是(可補、可多組) | HP-2026083100417 |
理由很實際:醫囑在還沒送 HIS 之前就已經存在(draft),那時候根本沒有處方號。 如果拿處方號當 id,草稿階段就沒有 URL 可以讀寫。
{
"resourceType": "MedicationRequest",
"id": "mr-efferalgan-0831", // URL:/MedicationRequest/mr-efferalgan-0831
"identifier": [
{ "system": "http://icu.emr.local/CodeSystem/his-prescription-number",
"value": "HP-2026083100417" } // HIS 回來之後才有
]
}
搜尋 ?identifier=HP-2026083100417 就是查 his_id_map(或業務表的識別碼欄位),不是查主鍵。
7.6 讀:一筆資源怎麼從表組出來
7.6.1 MedicationRequest
對映沿用 02 §2.3,這裡補上 SQL 與組裝。
SELECT r.logical_id, r.current_version, r.last_updated,
m.medication_code, m.ten_thuoc, m.dose_value, m.dose_unit,
m.route, m.frequency, m.cach_dung_thuoc, m.rate_value, m.rate_unit,
m.period_start, m.period_end, m.prescriber_id, m.order_set_id,
m.status, m.his_transmission_status, m.source_composition_id, m.source_composition_version,
m.catalog_version, m.catalog_source_sha256
FROM medication_request m
JOIN fhir_resource r
ON r.source_table = 'medication_request' AND r.source_pk = m.med_request_id
WHERE r.resource_type = 'MedicationRequest' AND r.logical_id = $1;
組裝規則:
| 表欄位 | FHIR 元素 | 規則 |
|---|---|---|
medication_code |
medicationCodeableConcept.coding[system=his-medication].code |
必帶 system+code,只有 text 要拒(CODE-REQUIRED) |
ten_thuoc |
.coding[0].display + .text |
顯示名用來源原文(越文),中文譯名放 coding.extension[reviewTranslation] |
dose_value / dose_unit |
dosageInstruction[0].doseAndRate[0].doseQuantity |
system 填 UCUM;院內單位未對照完成時填院內 CodeSystem |
route |
dosageInstruction[0].route.coding |
同樣必帶 system+code |
frequency |
dosageInstruction[0].timing.repeat |
BID → {frequency:2, period:1, periodUnit:'d'} |
rate_value |
dosageInstruction[0].doseAndRate[0].rateQuantity |
需單位與濃度,不可把 mg/kg/min 當成 mL/h |
status |
status |
臨床狀態軸,見 04 §4.1 |
his_transmission_status |
extension[hisTransmissionStatus] |
獨立的第三軸,不可併進 status |
source_composition_id + _version |
supportingInformation[0].reference |
組成 Composition/{id}/_history/{version} |
catalog_version、catalog_source_sha256 |
extension[catalogSnapshot] |
開立當下的目錄快照,稽核可辯護性的基礎 |
NULL 一律省略該元素,不要送 0、""、false。 這是 01 §1.3 缺值不補,
不是風格偏好:0 在臨床資料裡是一個真實數值(見錯誤碼 ZERO-AS-MISSING)。
確實需要表達「已知缺漏」時用 dataAbsentReason。
7.6.2 保留「給人看的原文」,否則畫面會走樣
結構化與顯示是兩件事。把 200 mg(1 錠) 拆成 {value: 200, unit: 'mg'} 之後,
再組回去只剩 200 mg——括號裡的包裝寫法沒了,臨床看到的字就跟原本不一樣。
頻次 BID(每日 2 次) 也一樣:從 timing.repeat.frequency 重組只會得到「每日 2 次」。
所以兩種都要存:結構化的值給機器算,原文給人看。FHIR 本來就留了位置:
| 內容 | 結構化 | 原文 |
|---|---|---|
| 藥物劑量與用法 | doseAndRate[0].doseQuantity、timing.repeat |
dosageInstruction[0].text |
| 實際給藥劑量 | dosage.dose |
dosage.text |
| 醫令的執行方式與數量 | quantityInteger |
orderDetail[](以 coding.code 分 route/quantity) |
| 醫令頻次 | occurrenceTiming.repeat |
occurrenceTiming.code.text |
| 資料來源說明 | — | meta.source |
對應到資料表就是多幾個 *_text 欄位。這不是冗餘:
沒有原文,畫面就只能自己拼字串,而拼出來的跟醫師當初打的不一樣。
7.6.3 Observation:一列一值,血壓例外
observation 是長表(一列 = 一個 code + 一個值 + 一個單位 + 一個時點 + 誰量的)。
多數情況一列對一個 Observation,血壓、PEWS、Apgar 這類多子項的量測要靠 observation_component:
SELECT o.observation_id, o.code, o.value_num, o.unit, o.effective_time, o.performer_id,
c.component_id, c.code AS component_code, c.value_num AS component_value, c.unit AS component_unit
FROM observation o
LEFT JOIN observation_component c ON c.observation_id = o.observation_id
WHERE o.patient_id = $1
AND o.category = 'vital-signs'
AND o.effective_time >= $2 AND o.effective_time <= $3
ORDER BY o.effective_time DESC, c.display_order;
有 component 的列:主列不放 valueQuantity,值全放在 component[]。
收縮壓與舒張壓不可拆成兩筆獨立 Observation(BP-COMPONENTS-REQUIRED),
否則時間軸上會失去配對,趨勢圖就畫錯了。
7.6.4 DiagnosticReport:表頭 + 逐項
一份報告 1 : N 個 observation,這個層級是 D1 的 ClinicalResultAvailable 合約就決定好的:
diagnostic_report(表頭)──> Observation[](每個檢驗指標一筆)
└──> specimen ──> microbiology_isolate ──> antimicrobial_susceptibility
每個指標必須是獨立的 Observation,不可以塞進 conclusion 的文字裡。 趨勢圖只有這種結構撐得住。
7.7 搜尋:query 參數怎麼翻成 SQL
支援的參數清單在 03 §3.6.2。翻譯規則只有四種型別:
| 型別 | 參數例 | 寫法 | SQL |
|---|---|---|---|
reference |
patient=Patient/demo-a |
也接受純 id | WHERE patient_id = :id |
token |
status=activecode=http://loinc.org|8867-4 |
system|code、|code、code 三種形式 |
WHERE status = :vWHERE code_system = :s AND code = :c |
date |
date=ge2026-08-31T07:00:00+07:00 |
前綴 eq(預設)ne gt lt ge le |
WHERE effective_time >= :v |
string |
name=Nguyen |
預設前綴比對,:exact 為完全相等 |
WHERE name ILIKE :v || '%' |
同一參數重複出現= AND(時間窗就是這樣表達);
逗號分隔的多值= OR(status=active,on-hold)。
7.7.1 時間窗直接對映
GET /Observation?patient=Patient/demo-a&category=vital-signs
&date=ge2026-08-30T19:00:00+07:00&date=le2026-08-31T19:00:00+07:00
WHERE patient_id = :patient
AND category = 'vital-signs'
AND effective_time >= :from
AND effective_time <= :to
伺服器不提供 window=shift 這種語意參數。 班別起訖是排班資料,換算責任在前端;
伺服器對「本班」有隱含定義,之後一定會跟排班表對不上。
7.7.2 分頁用 keyset,不要用 OFFSET
臨床資料一直在寫入,OFFSET 會讓第 2 頁漏資料或重複。
-- 第一頁
SELECT ... WHERE patient_id = :p ORDER BY effective_time DESC, observation_id DESC LIMIT 21;
-- 下一頁(cursor 來自前一頁最後一列)
SELECT ... WHERE patient_id = :p
AND (effective_time, observation_id) < (:last_time, :last_id)
ORDER BY effective_time DESC, observation_id DESC LIMIT 21;
多取一列用來判斷還有沒有下一頁;有的話就放 Bundle.link[relation=next],URL 裡帶不透明的 cursor。
前端不得自己拼 offset,一律跟隨 next(03 §3.6.1)。
_count 預設 20、上限 200。_total=accurate 要另跑一次 COUNT(*),成本自負;
沒帶就不回 Bundle.total(回 0 是錯的,那代表「查無資料」)。
7.7.3 _include / _revinclude:第二段查詢
不必做成 JOIN。先查主結果,收集要帶的 reference,再查一次,放進同一個 Bundle,
差別只在 entry.search.mode:
GET /DiagnosticReport?patient=Patient/demo-a&date=ge2026-08-31
&_include=DiagnosticReport:result
&_revinclude=Provenance:target
① 主查詢 → diagnostic_report 列 → entry.search.mode = "match"
② _include → 這些報告的 observation 列 → mode = "include"
③ _revinclude → target 指向這些報告的 provenance → mode = "include"
mode=include 的資源不算在 Bundle.total 裡,也不該被前端當成搜尋命中。
支援的組合就是 03 §3.6.4 列的那幾個,不必做成通用機制。
7.7.4 明確不做
chained search(patient.name=…)、_filter、_has 以外的反向查詢、模糊比對、
「找不到就回相近項目」——全部不做。code 查不到就是空集合。
interpretation 沒填不代表正常,不可以在查詢時把它歸進 normal(BR-RPT 系列)。
B 目錄的品項查詢(藥品/服務/ICD 共 31,146 筆)走 ValueSet/$expand,不走資源搜尋。
7.8 寫:版本、樂觀鎖、ETag
7.8.1 一次寫入的完整流程
① 驗證 If-Match 存在 缺 → 412 PRECONDITION-REQUIRED
② 驗證權限與病人範圍 不符 → 403
③ 驗證業務規則(BR-*) 不符 → 422
④ BEGIN
⑤ UPDATE 業務表 ... WHERE pk = :pk AND version = :ifMatch 影響 0 列 → ROLLBACK, 409
⑥ INSERT fhir_resource_version(新版本的 JSON 快照)
⑦ UPDATE fhir_resource SET current_version = current_version + 1, last_updated = now()
⑧ INSERT audit_event
⑨ COMMIT 失敗 → 500,且資源必須不存在
⑩ 回 200/201 + ETag: W/"{新版本}"
第 ⑤ 步是樂觀鎖的全部:用 WHERE version = :ifMatch 讓資料庫判斷,不要先 SELECT 再比較——
先查再寫之間有競態,兩個人可以同時通過檢查。
UPDATE medication_request
SET dose_value = :dose, version = version + 1, last_updated = now()
WHERE med_request_id = :pk
AND version = :if_match; -- 影響 0 列 ⇒ 版本已被別人推進 ⇒ 409
409 的 OperationOutcome 必須帶目前版本(details.text = "currentVersionId=6"),
前端才能就地合併而不丟掉使用者輸入。
7.8.2 保存失敗就不能回 2xx
原型(icu-vue)在持久化失敗時仍把交易留在記憶體並顯示警示,那是 localStorage 的權宜做法。
正式 API 不得如此(05 §5.4):
沒寫進資料庫就回 500,資源必須不存在。保留使用者輸入是前端的責任,不是靠伺服器回假成功。
7.8.3 沒有 DELETE
任何資源都不提供 DELETE。誤建資料用 PATCH 標記:
PATCH /MedicationRequest/mr-efferalgan-0831
Content-Type: application/json-patch+json
If-Match: W/"3"
[{ "op": "replace", "path": "/status", "value": "entered-in-error" }]
後端要做的是:正常進版 + 產生 AuditEvent(action=D)。
標為 entered-in-error 的資源仍可 read,但預設不出現在搜尋結果——
所有搜尋預設加上 AND status <> 'entered-in-error',除非查詢明寫 status=entered-in-error。
7.8.4 建立後不可改的四種資源
MedicationAdministration、Procedure、Observation、Communication 是已發生事實的紀錄,
只能標 entered-in-error 後重建,不提供 update。路由層直接不註冊 PUT 就好。
7.9 Bundle transaction
7.9.1 內部引用(urn:uuid)的解析
一次開立多筆醫囑、簽署(文件+Provenance)、接收交班(Provenance+多筆 Task)
都必須是 type=transaction,全成功或全失敗。難點只有一個:entry 之間會互相引用。
{
"resourceType": "Bundle", "type": "transaction",
"entry": [
{ "fullUrl": "urn:uuid:...0001",
"resource": { "resourceType": "Composition", ... },
"request": { "method": "POST", "url": "Composition" } },
{ "fullUrl": "urn:uuid:...0002",
"resource": { "resourceType": "Provenance",
"target": [{ "reference": "urn:uuid:...0001" }] }, // 指向上一筆
"request": { "method": "POST", "url": "Provenance" } }
]
}
做法(兩階段,最不容易出錯):
① 開一個 DB transaction
② 先為每個 POST entry 配發 logical_id,建立 urn:uuid → "Composition/xxx" 的對照表
③ 依對照表把所有 entry 的 reference 就地改寫
④ 逐筆寫入(此時已沒有 urn:uuid)
⑤ 全成功 → COMMIT,回 transaction-response(entry 順序與請求相同)
任一筆失敗 → ROLLBACK,回單一 OperationOutcome,且指出是第幾筆
transaction-response 的每個 entry 只回 response(status/location/etag/lastModified),
不必回整份資源。
batch 只用於純讀取的批次載入,任何寫入不得用 batch。
7.9.2 條件式建立:用資料庫的唯一索引,不要先查再寫
劑次唯一性(同一劑不可重複給藥)是安全問題,不能靠應用層檢查:
POST /MedicationAdministration
If-None-Exist: identifier=http://icu.emr.local/occurrence-id|M3-V1-D1&status=completed
CREATE UNIQUE INDEX ux_med_admin_occurrence
ON medication_administration (occurrence_id)
WHERE status = 'completed'; -- 部分索引:只有完成的劑次互斥
寫入時直接 INSERT,捕捉唯一鍵衝突:衝突就回 200 OK + 既有資源(不是 201、不是 409),
前端據此顯示「本劑次已有完成紀錄」。這比先查再寫安全:兩個護理師同時操作時,
資料庫只會讓一個成功。
7.10 自訂操作($sign / $submit / $receive / $roster)
這四支不是「另一種風格的 REST」,它們存在的唯一理由是多個副作用必須原子完成。
| 操作 | 一個交易內要完成的事 |
|---|---|
POST /MedicationRequest/{id}/$sign |
驗證病程來源存在且同病人同作者 → 把病程當下版本寫進 supportingInformation → 建立 Provenance.signature → 醫囑進版 |
POST /MedicationRequest/{id}/$submit |
產生 outbound_request(含冪等鍵)→ 呼叫 HIS → 落回 hisTransmissionStatus 與處方號 |
POST /Composition/{id}/$sign |
文件轉 final → 建立 Provenance.signature |
POST /Composition/{id}/$receive |
建立接收 Provenance → 同一交易內轉移 Task.owner |
GET /Patient/$roster |
回登入者的照護名單(Patient + CareTeam),省掉前端自己拼 _has |
$submit 的冪等
CREATE TABLE outbound_request (
idempotency_key varchar(128) PRIMARY KEY, -- resource_type + logical_id + version_id
event_type varchar(64) NOT NULL, -- 'PrescriptionCreated'
payload jsonb NOT NULL,
status varchar(16) NOT NULL, -- pending | sent | accepted | rejected | unknown
his_response jsonb,
sent_at timestamptz,
responded_at timestamptz
);
同一資源同一版本重送,回第一次的結果,不重打 HIS。
status = unknown(逾時)時禁止自動重送,回 409 RESEND-BLOCKED-UNKNOWN,
改開一件人工查明的 Task——自動重送會造成重複處方(06 §6.6)。
7.11 錯誤:一個中介層做完
不要在每支端點各寫一段錯誤處理。定義一個業務規則例外,在最外層轉成 OperationOutcome:
// 丟出
throw new BusinessRuleError('FIVE-RIGHTS-INCOMPLETE', {
http: 422,
issueCode: 'business-rule',
diagnostics: '需逐項人工確認五對,不能由單次掃碼推定。',
expression: ["MedicationAdministration.extension('…/verificationChecklist')"],
})
// 中介層統一轉換
{
resourceType: 'OperationOutcome',
issue: [{
severity: 'error',
code: err.issueCode,
details: { coding: [{ system: 'http://icu.emr.local/CodeSystem/api-error', code: err.code }] },
diagnostics: err.diagnostics,
expression: err.expression,
}],
}
四個欄位缺一不可(05 §5.1)。
錯誤碼表(61 個)在 05 §5.2,
Swagger 的 ApiErrorCode schema 是同一份列舉。
diagnostics 是給臨床看的,不是給工程師看的
它會被前端直接顯示在畫面上,所以寫「需逐項人工確認五對」,不寫 validation failed at field 3。
越南語版本另建訊息表以 code 為鍵,不要在程式裡寫死字串。
最常搞錯的三個界線
| 情況 | 碼 | 判準 |
|---|---|---|
| 沒有權限(角色、病人範圍) | 403 | 你不能做這件事 |
| 狀態不允許、版本衝突 | 409 | 現在不能做這件事,之後可能可以 |
| 結構合法但違反業務規則 | 422 | 這份資料本身不合規 |
不因無權而回 404。 資源不存在才 404。 但搜尋是例外:回較少的結果,不要回 403,否則會變成探測病人是否存在的管道。
7.12 稽核與溯源
AuditEvent |
Provenance |
|
|---|---|---|
| 誰產生 | 伺服器自動產生,客戶端不可寫 | 客戶端建立(簽署、判讀、核對、交班接收) |
| 記什麼 | 誰在什麼時候存取/改了什麼(系統事實) | 誰用什麼身分做了什麼臨床動作(臨床事實) |
| 可否修改 | 否 | 否,只新增 |
每個請求(含讀取)都要寫 AuditEvent,必填欄位見 05 §5.6。
實作上放在同一個中介層,成功與失敗都記(outcome)。
「已閱」用 HL7 標準碼 v3-DataOperation#READ;
「判讀」「核對」「交班接收」「名單確認」是臨床業務動作,標準碼沒有對應語義,
一律用本地 CodeSystem provenance-activity(02 §2.12)。不要硬套。
7.13 安全:病人範圍要變成 SQL 條件
SMART on FHIR 的 scope 檢查(user/MedicationRequest.c 之類)只是第一道。
真正要做對的是病人範圍:使用者只能看到自己照護範圍內的病人。
-- 每個病人相關查詢都自動追加這個條件,不要靠各端點自己記得加
AND patient_id IN (
SELECT ct.patient_id FROM care_team ct
JOIN care_team_participant p ON p.care_team_id = ct.care_team_id
WHERE p.practitioner_id = :current_user
AND ct.status = 'active'
AND (ct.period_end IS NULL OR ct.period_end >= now())
)
把它做成資料存取層的強制條件(例如每個 repository 都吃一個 AccessScope 參數),
而不是每支端點自己拼——漏掉一支就是一次病歷外洩。
照會醫師是特例:能讀該病人資料、能回覆照會,但不能開立醫囑(CONSULT-READ-ONLY),
而且他不在 CareTeam 裡,範圍要由 ServiceRequest(category=consultation).performer 推導。
7.14 前端(Vue 3)怎麼呼叫
前端是 Vue 3 + Pinia(icu-vue)。對後端的意義是三件事:ETag 要保存、409 要能合併、
一次載入要用 _include。以下是前端已經在用的形狀,後端照這個回就對得上。
7.14.1 最小 client
// src/api/fhirClient.js
const BASE = import.meta.env.VITE_FHIR_BASE // https://…/fhir/r4
const JSON_FHIR = 'application/fhir+json'
/** ETag 由 client 統一保管,畫面不必自己記版本 */
const etags = new Map() // "MedicationRequest/mr-1" → 'W/"3"'
async function request(method, path, { body, headers = {} } = {}) {
const res = await fetch(BASE + path, {
method,
headers: { Accept: JSON_FHIR, ...(body ? { 'Content-Type': JSON_FHIR } : {}), ...headers },
body: body ? JSON.stringify(body) : undefined,
})
const etag = res.headers.get('ETag')
const payload = res.status === 204 ? null : await res.json()
if (!res.ok) throw new FhirError(res.status, payload) // payload 一定是 OperationOutcome
if (etag && payload?.resourceType && payload?.id) etags.set(`${payload.resourceType}/${payload.id}`, etag)
return payload
}
export const read = path => request('GET', path)
export function update(resource) {
const key = `${resource.resourceType}/${resource.id}`
const etag = etags.get(key)
if (!etag) throw new Error(`${key} 沒有 ETag:請先讀取再更新,不可盲目覆寫`)
return request('PUT', `/${key}`, { body: resource, headers: { 'If-Match': etag } })
}
export class FhirError extends Error {
constructor(status, outcome) {
const issue = outcome?.issue?.[0]
super(issue?.diagnostics ?? `HTTP ${status}`)
this.status = status
this.code = issue?.details?.coding?.[0]?.code // 本院錯誤碼,用來決定畫面行為
this.expression = issue?.expression // 要標紅的欄位
this.outcome = outcome
}
}
重點:diagnostics 直接顯示給使用者,code 用來決定畫面行為(例如 ROSTER-NOT-CONFIRMED
就跳去確認名單),expression 用來標出出錯的欄位。這三個用途分開,畫面才不必解析字串。
7.14.2 409 要保留使用者輸入
try {
await update(draft)
} catch (e) {
if (e.status === 409) {
// 伺服器帶回 currentVersionId=6;重新讀取後把使用者改過的欄位套上去,
// 不要直接丟掉畫面內容(後端保證內容未被覆寫)
const latest = await read(`/Composition/${draft.id}`)
showMergeDialog(latest, draft)
} else if (e.status === 412) {
// 沒帶 If-Match:這是前端的 bug,不是使用者的問題
}
}
7.14.3 一次載入病人工作區
// S13 檢驗結果頁:報告 + 逐項數值 + 已閱判讀,一次 request
const bundle = await read(
`/DiagnosticReport?patient=Patient/${pid}&date=ge${from}` +
`&_include=DiagnosticReport:result&_revinclude=Provenance:target`)
const matched = bundle.entry.filter(e => e.search?.mode === 'match').map(e => e.resource)
const included = bundle.entry.filter(e => e.search?.mode === 'include').map(e => e.resource)
Pinia store 只存這個 Bundle 拆出來的結果,不要在 store 裡再造一套自己的資料模型——
原型那層 store.js 的 mutation 介面保留,內部改成呼叫上面的 client 即可。
7.15 建議的交付順序
每個階段都能獨立驗收,前端也能同步接上,不必等整套做完。
| 階段 | 後端做什麼 | 前端這時能做什麼 | 驗收 |
|---|---|---|---|
| M1 讀路徑 | 唯讀鏡像 13 個物件的 GET + 搜尋;/metadata |
病人總覽、生命徵象、檢驗結果三頁接真資料 | 用本文件的範例逐支比對回應形狀 |
| M2 寫路徑 | EMR 自有資源的 create/update、版本表、ETag、OperationOutcome |
病程撰寫、醫囑開立(草稿) | 併行更新測試:兩個工作階段同時改同一份病程,一個成功一個 409 |
| M3 原子操作 | Bundle transaction、$sign/$receive、條件式建立 |
簽署、交班、eMAR 給藥 | 劑次重複測試:同一劑次併發 POST 兩次,只有一筆完成紀錄 |
| M4 HIS 整合 | $submit、outbound_request、D1 事件落回 |
醫囑送出與狀態顯示 | 逾時情境:HIS 不回應時本地狀態為 sent+unknown,且不自動重送 |
7.16 後端自測清單
做完可以自己勾。每一條都對應本文件或 04/05 的規則,不是風格建議。
基本
- 所有回應的
Content-Type是application/fhir+json - 未帶
Accept也回application/fhir+json,不做內容協商猜測 -
GET /metadata回得出CapabilityStatement,且與實際支援的互動一致 - 所有時間都帶時區(
+07:00),沒有裸的 local time
讀
- 每個
read回應帶ETag與Last-Modified,If-None-Match命中回 304 - NULL 欄位省略,沒有
"value": 0或"text": ""代表缺值的情況 - 血壓回的是一筆
Observation+ 兩個component,不是兩筆Observation - 檢驗報告的每個指標是獨立
Observation,不是塞在conclusion文字裡 - 搜尋預設排除
entered-in-error,明寫status=entered-in-error才查得到 - 分頁跟隨
Bundle.link[next],第 2 頁不會因為新資料寫入而漏或重複 -
_include的資源search.mode=include,且不計入Bundle.total
寫
- 缺
If-Match回 412,不是 400、不是 428 - 版本落後回 409,且
OperationOutcome帶目前版本 - 併發更新測試:同時兩個 PUT,只有一個成功
- 沒有任何端點提供
DELETE -
MedicationAdministration/Procedure/Observation/Communication沒有 PUT - 持久化失敗回 500,且資源事後查不到(沒有假成功)
- 每次寫入都新增一列
fhir_resource_version,且該表禁止 UPDATE/DELETE(DB 權限層面)
原子性
- transaction Bundle 任一筆失敗,整包回滾,沒有半套資料
- transaction 內的
urn:uuid引用能正確解析成真實 id - 劑次重複測試:併發兩次相同
If-None-Exist,只產生一筆,第二次回 200 而非 201 -
$receive的Task.owner轉移與Provenance建立在同一個交易內
安全與稽核
- 病人範圍條件在資料存取層強制加入,不是各端點自己拼
- 照會醫師可讀該病人、可回覆照會,但開立醫囑回
CONSULT-READ-ONLY - 臨床帳號寫 HIS 唯讀資源回
422 READ-ONLY-SOURCE(不是 403) - 無權存取回 403,資源不存在回 404,兩者不混用
- 每個請求(含讀取)都產生
AuditEvent -
Provenance只能新增,沒有 update 路徑
HIS 整合
-
$submit同資源同版本重送不會重打 HIS - HIS 逾時後狀態為
unknown,且禁止自動重送(回 409 並開人工查明工作) - HIS 的
MedicationChangeStatus1–5 沒有任何一個被當成「已給藥」
附錄 A · 自建資料表 → FHIR 資源 → 端點
Phase 1 ★必要的物件。完整欄位定義見 G7_物件欄位規格.xlsx,權威歸屬見 G1。
| 自建表 | 主鍵 | FHIR 資源 | 端點 | 寫入者 |
|---|---|---|---|---|
patient + patient_identifier |
patient_id |
Patient |
GET /Patient、GET /Patient/{id} |
整合層(HIS) |
encounter + encounter_location |
encounter_id |
Encounter + Location |
GET /Encounter |
整合層(HIS) |
practitioner |
practitioner_id |
Practitioner / PractitionerRole |
由 Reference 帶出 | 整合層(HIS) |
care_assignment |
— | CareTeam + Provenance |
GET/POST /CareTeam、GET /Patient/$roster |
臨床端 |
condition |
condition_id |
Condition |
由 Composition 引用 | 臨床端 |
medication_request |
med_request_id |
MedicationRequest |
/MedicationRequest、$sign、$submit |
臨床端 → HIS |
medication_administration |
med_admin_id |
MedicationAdministration |
/MedicationAdministration |
臨床端 |
medication_dispense |
dispense_id |
MedicationDispense |
唯讀 | 整合層(HIS) |
service_request |
service_request_id |
ServiceRequest |
/ServiceRequest |
臨床端 → HIS |
procedure + procedure_performer |
procedure_id |
Procedure |
/Procedure |
臨床端 |
observation + observation_component |
observation_id |
Observation |
/Observation |
臨床端/設備/HIS |
diagnostic_report |
report_id |
DiagnosticReport |
GET /DiagnosticReport |
整合層(HIS) |
specimen |
specimen_id |
Specimen |
由 Reference 帶出 | 整合層(HIS) |
composition + composition_section |
composition_id |
Composition |
/Composition、$sign、$receive |
臨床端 |
clinical_task |
— | Task |
/Task |
臨床端 |
signature |
signature_id |
Provenance.signature |
由 $sign 產生 |
伺服器 |
audit_event |
— | AuditEvent |
GET /AuditEvent |
伺服器 |
form_definition + form_field_binding |
form_id |
Questionnaire + Questionnaire.item |
later-work | — |
checklist_response |
checklist_resp_id |
QuestionnaireResponse |
later-work | 臨床端 |
order_execution_link 不需要對應資源:MedicationAdministration.request 與 Procedure.basedOn
就是那條連結,版本鎖定靠帶版本的 Reference。
附錄 B · 這份規格明確不做的事
不必實作,也不要「順手做一下」——每一項都是刻意排除的,做了反而要回頭拆。
| 不做 | 理由 |
|---|---|
DELETE |
病歷不刪除,改標 entered-in-error |
條件式更新(PUT /X?params) |
語義模糊 |
chained search、_filter、GraphQL |
不在 Phase 1 範圍,且沒有 HAPI 就不划算 |
| Subscription/WebSocket 推播 | 本輪不做自動警報,H8 已裁示 |
window=shift 之類的語意查詢參數 |
班別換算屬排班資料,責任在前端 |
| 自動重送 HIS | 可能造成重複處方,一律人工確認 |
| 伺服器端計算年齡、I/O 平衡的預設值 | 缺值不補;沒有來源就不要生出數字 |
| XML 格式 | 只支援 application/fhir+json |
未決事項對後端的影響
README 的 Q-01~Q-06 會直接卡到實作,這裡標出卡在哪:
| 編號 | 事項 | 卡住什麼 |
|---|---|---|
| Q-01 | 院區時區是否固定 +07:00 |
所有時間欄位的儲存與比較。建議一律存 UTC,回應時轉 +07:00,等院方確認後不必改資料 |
| Q-02 | 檢驗指標的 LOINC 或院內字典 | Observation.code 的 system。先用院內碼 his-lab-indicator,之後加一個 coding,不要換掉原本的 |
| Q-03 | 出入量項目對照 | I/O 平衡的計算與呈現,Phase 1 可先只存不算 |
| Q-04 | SBAR 交班的 Composition.type 代碼 |
交班文件的檢索;先用本地碼 handover-sbar |
| Q-05 | repeatPeriod/priority 值域 |
送 HIS 前的阻擋條件;EMR 內部先用 FHIR priority,對映在整合層 |
| Q-06 | 部分成功契約 | ServiceRequestCreated 的逐行對帳與補償邏輯 |
共同原則:未定的先用本地碼,之後用「新增 coding」的方式補標準碼,不要換掉既有的。 這樣已經寫進去的資料不用回頭改。