image-20251009102618401

(圖片來源: 數位憑證皮夾官方網站)

前提:

上一篇入門版做了一個 HR 員工卡系統:同仁自己申請一張員工卡,然後拿它去申請「運動補助」跟「育兒補助」。那一篇的重點是「發卡(Issuer)」跟「驗證(Verifier)」兩個角色分開來看。

這一篇想再往前走一步:如果一個場景要同時扮演驗證方跟發行方,串成一條完整的 DID 生態鏈,會長什麼樣子?我挑的場景是「訪客背書發證」——這也是我在腦力激盪五個檢驗方應用時,覺得最能展示「全鏈」的一個。

順便,這篇會很誠實地把開發過程中踩到的三個坑寫下來,因為那些才是 TIL 真正有價值的部分。

程式碼在這裡:https://github.com/kkdai/did-usecase-visitor 線上體驗:https://did-usecase-visitor-660825558664.asia-east1.run.app

場景:員工門禁 + 訪客背書發證

image-20260709172435871

這個大廳證件台有兩個模式:

  1. 員工門禁 / 活動報名:員工用數位皮夾出示員工卡,系統只驗證「是不是有效員工」,驗過就開門 / 報名成功。姓名、生日、子女數這些欄位一律不揭露,留在皮夾裡——這就是選擇性揭露。
  2. 訪客背書發證(這篇的主角):由一位在職員工出示員工卡「背書」,驗證通過之後,系統當場核發一張帶到期時間的臨時訪客通行證到訪客的皮夾。

第二個模式的價值在於,它把兩個角色接起來了:

先當 Verifier(驗員工卡)→ 驗過才當 Issuer(發訪客卡)

比起傳統的紙本訪客簿(抄身分證、押證件影本、一堆個資堆在櫃台還要人工回收),數位背書只留下「哪位員工背書」這一筆可追責的資訊,訪客資料留在訪客自己的皮夾,通行證還可以設到期時間。

架構決策:為什麼不直接改上一個專案

這次我開了一個全新的專案、部署到獨立的 Cloud Run 服務,而不是在原本的 HR 專案上加頁面。幾個考量:

  • 靜態前端 + JSON API:原專案用 jade 樣板 server-side render,這次改成 public/ 靜態頁 + 幾支 JSON API(/api/access/qrcode/api/access/status),前後端分得比較乾淨。
  • 把皮夾 API 呼叫抽成 lib/wallet.js:原專案的 issuer / verifier 呼叫是內嵌在路由裡、而且重複。這次抽成三個函式:requestPresentationQRCode()getPresentationResult()issueCredential(),好維護也好測。
  • 狀態改用記憶體:原專案把資料寫進單一 record.js 檔案,在 Cloud Run 這種無狀態環境上寫檔會有問題。這次用簡單的記憶體物件(重啟歸零,展示用途足夠)。
  • 權杖沿用同一個沙盒帳號:issuer / verifier 的 access token 跟上一篇是同一組,直接重用。

驗證「持有員工卡」的部分,我先沿用既有的運動補助 verifier ref 當 fallback(VERIFIER_ACCESS_REF 沒設就用 VERIFIER_SPORT_REF),這樣不用等後台設定就能先跑起來。

踩坑紀錄一:出示成功了,畫面卻一直卡住

這是最經典的一個。手機掃碼、皮夾也完成出示了,但桌面的頁面就是不往下走,一直在輪詢。

第一步先看 Cloud Run 的日誌,發現 /api/access/status 每 3 秒回一次、每次都回「未驗證」。我在後端加了一行把驗證方原始回應印出來的 log,重新部署後再測一次,就抓到真相了:

{
  "data": [
    {
      "credentialType": "0028680530_line_school",
      "claims": [
        { "ename": "english_name", "cname": "英文名字", "value": "Lub" },
        { "ename": "join_company", "cname": "入職時間", "value": "2018-10-05" }
      ]
    }
  ],
  "verifyResult": true,
  "resultDescription": "success",
  "transactionId": "8cd7f37b-..."
}

看到問題了嗎?回應裡的欄位是 verifyResult(camelCase),而且根本沒有 code 這個欄位。但我沿用上一篇的舊寫法,判斷式是:

// 舊的(對不上現在的回應)
const verified = data.code === 0 && data.verify_result === true;

data.codeundefineddata.verify_result 也是 undefined(人家叫 verifyResult),所以永遠 false,永遠 pending。其實驗證早就成功了verifyResult: trueresultDescription: "success"),只是我判斷的欄位名對不上——看起來沙盒 API 的回應格式已經從 snake_case 換成 camelCase 了。

修法就是把判斷式改成相容兩種格式:

const verified =
  data.verifyResult === true ||        // 新格式 camelCase
  data.verify_result === true ||       // 舊格式相容
  (data.code === 0 && data.verify_result === true);

TIL:接第三方 API,不要相信「上一版能動的判斷式這一版也能動」。沙盒會改。加一行印出原始回應的 log,比對著改,比盯著程式碼猜半天快多了。

踩坑紀錄二:訪客卡一直「待發」

門禁那關通了之後,換訪客背書那關卡住——畫面顯示「訪客卡待發(issuer 樣板未設定)」,沒有真的發出一張卡。

我直接拿 curl 打發卡 API /api/vc-item-data 來看它到底回什麼。分兩種情況測:

情況 A:用員工樣板 + 正確的員工欄位 → HTTP 200,而且完整回應裡有這些 key:

KEYS: [ 'id', 'content', 'pureContent', ..., 'qrCode', 'deepLink', 'expired', ... ]
qrCode   = data:image/png;base64,iVBOR...      ← 真的能掃進皮夾的領卡 QR
deepLink = https://frontend-uat.wallet.gov.tw/api/moda/vcqrcode?...
expired  = 2027-01-09T...

情況 B:用員工樣板 + 訪客欄位visitor_typeendorsed_by…) → HTTP 500 / 400 BAD_REQUEST。

原因很清楚了:員工樣板的欄位是 isRequired: true(姓名、英文名字…),我卻送了一堆它沒有的訪客欄位,就被打槍。而發卡成功時的回應其實就帶了 qrCodedeepLink,可以直接讓訪客掃碼領卡——我原本的解析是對的,卡關的純粹是「欄位對不上樣板」。

於是我設計了兩種發卡模式,用環境變數自動切換(程式裡的 HAS_VISITOR_TEMPLATE):

模式 條件 行為 卡面
Option 1(fallback) 沒設 VISITOR_VC_* 借用員工樣板,把訪客資訊塞進它的必填欄位(姓名=「臨時訪客」等)發卡 顯示為員工卡卡面
Option 2(正規) 有設 VISITOR_VC_* visitor_type / endorsed_by / valid_until 到專屬訪客樣板 正規訪客通行證卡面

Option 1 的好處是不用等後台設定就能發出一張真的能領的卡(雖然卡面是借來的),先把整條鏈跑通;要正規卡面再走 Option 2 建專屬樣板即可,程式碼一行都不用改。

踩坑紀錄三:領卡 QR 太小 + 手機版面

第一版我把訪客通行證做成一張漂亮的小識別證,領卡 QR 只有 48px——結果就是根本掃不到。這個 QR 是要給「另一支手機」掃來領卡的,太小就失去意義。

後來把訪客證改成直式卡片,領卡 QR 放大成卡片主體(最大 240px、白底留白),下面才放「背書員工 / 有效至」的資訊。兩個 QR(出示用、領卡用)也都改成 clamp() 響應式尺寸,手機上不爆版、桌機上夠清楚。

TIL:只要是「給別人掃」的 QR,就要當成主角來排版,不能當裝飾。

關於「限時自動失效」的真相

我原本以為可以逐張指定「這張訪客證 4 小時後過期」,但實測發現:透過 /api/vc-item-data 發卡,卡片的實際有效期是跟著樣板設定走的(例如員工樣板是發卡日 +約半年),沒辦法一張一張指定短效期。

所以現在卡面上的「有效至 HH:MM」是應用層自己算的顯示值,不是皮夾強制的到期。如果要真正的短效訪客證,有兩條路:

  • 建立訪客樣板時,把樣板的有效期直接設短
  • 或改用平台的排程撤銷(revoke)——發卡回應裡有 clearScheduleIdscheduleRevokeMessage 這些欄位,暗示平台支援排程撤銷,但要另外串接對應 API。

部署:從原始碼直接上 Cloud Run

這次用 buildpacks 從原始碼直接部署,不用自己寫 Dockerfile:

gcloud run deploy did-usecase-visitor \
  --source=. --region=asia-east1 --platform=managed --allow-unauthenticated \
  --set-env-vars="VC_SERNUM=607861,VC_UID=0028680530_line_school,\
ISSUER_ACCESS_TOKEN=...,VERIFIER_SPORT_REF=...,VERIFIER_ACCESS_TOKEN=...,\
VISITOR_TTL_HOURS=4"

之後要切換到 Option 2 的正規訪客卡,只要在這串 --set-env-vars 補上 VISITOR_VC_SERNUM=<新樣板 vcId>,VISITOR_VC_UID=<新樣板 vcCid> 重新部署即可, HAS_VISITOR_TEMPLATE 會自動變 true。

總結與未來展望

這次的重點不是「又做了一個 demo」,而是三件事:

  1. DID 全鏈是可行的:同一個場景同時當 Verifier 跟 Issuer,驗過一張卡再發一張卡,把生態鏈接起來,體驗上很順。
  2. 踩坑都在細節:欄位命名(verifyResult vs verify_result)、樣板必填欄位、QR 尺寸——這些不看原始回應、不實際用手機掃,是不會發現的。
  3. fallback 設計讓 demo 先能動:不用等後台把每個樣板 / ref 都建好,先用既有資源跑通,再逐步換成正規設定,開發節奏會好很多。

數位憑證皮夾能做的應用場景真的很多,「訪客背書」只是其中一個。上一篇的員工卡,其實還可以延伸出福利社優惠核銷、年資里程碑禮、親子設施門禁、健身房積點……每一個都是一個「檢驗方」的新應用。很期待看到更多有創意的場景被做出來。


Buy Me A Coffee

Evan

Attitude is everything