醫境媒合平台 · 內部技術分析

證照上傳與 admin 撮合名稱顯示
兩項回報問題的技術分析

分析日期:2026-07-20 HEAD:736db3c Production:yijingmatch.com 非法律意見 · 內部工作底稿

總結論:兩項都不是缺少功能,但問題 2 藏著一個真正的缺口

問題 1(證照上傳)根因已確認——測試人員在 localhost 測試,本機未設 OBJECT_STORAGE_* 環境變數,回 503。這是 runbook 明文記載的預期行為,不是 bug。功能本身四層全通。
問題 2(名稱顯示):功能已上線,回報畫面為舊版;但診所永遠無法填寫真實名稱——這是唯一真正需要處理的缺口。

問題 2admin 撮合 UI 顯示資料庫 ID 而非診所/醫師名稱

回報症狀:撮合記錄頁的「診所彙總」與「醫師」欄位顯示 6a55f496d145549b341c1f60 這類 24 碼 ObjectId,難以辨識是誰。

結論

名稱顯示功能(計畫 29)已於 2026-07-19 由 commit 435b448 實作完成,並隨同一次 push 部署至 production。但目前的實作在自助註冊的帳號上會全面 fallback 回 ID——因為那些帳號的名稱欄位根本是空的或無意義。

三條獨立證據:回報畫面並非現行版本

  1. 現行程式碼在結構上不可能印出完整 24 碼 ID apps/web/app/(admin)/admin/match-records/match-records-client.tsx:50
    function shortId(id: string): string {
      return id.length > 6 ? `…${id.slice(-6)}` : id;
    }

    畫面渲染的是「名稱(或替代文字)+ …41c1f60 尾 6 碼 + 複製按鈕」。完整 ID 只存在於 title 屬性與剪貼簿,永遠不會成為可見內文。回報畫面顯示完整 24 碼、無替代文字、無複製鈕,因此不是這版程式碼的輸出。

  2. Production 執行的是包含此修正的版本

    https://yijingmatch.com/ 取得的 HTML 中 <title> 為「醫境媒合平台」,即品牌變更 commit c75eacd。該 commit 與名稱 enrich 的 435b448 屬同一次 push,且時序在其之後——因此 enrich 必然已一併上線。

  3. Production 資料庫目前沒有任何撮合記錄

    TODO.md16.上線前檢查表-簽核.md 均記載:2026-07-17 冒煙測試後資料已清除,dcm_prod 僅剩 admin 帳號 1 筆。而回報畫面顯示「撮合記錄 3 筆」與三列醫師資料——現行 production 無法產生該畫面

最可能的來源

2026-07-01 那一輪 claude-in-chrome 人工驗收(記錄於 23.claude-in-chrome-手動驗收測試項目.md)。該時間點同時早於 R2 物件儲存開通(07-09)與計畫 29 名稱顯示(07-19)——單一張舊畫面恰好可以同時解釋這次回報的兩個症狀

名稱是怎麼被查出來的(現行實作)

服務層以批次 $in 查詢回填顯示名稱,屬 additive 欄位,不改變既有回應結構:

透過 gitnexus 的 call-graph 確認,enrichMatchRecordViews 的呼叫端確實涵蓋 listMatchRecordscreateMatchRecordsForJobPostupdateMatchRecord 三者;診所彙總另走 lookupClinicNames:456)。四條路徑全數接上,沒有遺漏——包含最初審查時擔心的「按更新後名稱消失」問題已被涵蓋。

真正的問題:名稱欄位在自助註冊時根本沒有被填入

這是本次分析最重要的發現。註冊流程建立角色殼時:

libs/auth/src/lib/register.ts:182(醫師)
const profile = await DoctorProfileModel.create({ accountId });
//                                              ↑ 完全沒有 fullName
libs/auth/src/lib/register.ts:119, 192(診所)
const displayName = email.split('@')[0];   // ← email 帳號前綴
const clinic = await ClinicModel.create({ accountId, name: displayName });

因此在真實使用情境下:

角色註冊後的名稱欄位admin 撮合頁實際顯示可用性
醫師(剛註冊,未建檔) fullName (未建檔名稱)+ …1c1f6a 無法辨識
醫師(已完成 profile 建檔) fullName = 本人填寫 真實姓名 正常
診所(自助註冊) name = email 前綴,如 smoke-clinic 顯示 smoke-clinic 有值但非真實診所名
已軟刪除的醫師/診所 deletedAt: null 濾除 替代文字+短 ID 刻意設計
白話說明

名稱顯示這個「管線」已經接好了,但管線兩端的水源是空的。醫師必須自己到 /profile 完成建檔才會有姓名;診所則從註冊那一刻起就只有一個從 email 推導出來的代號,平台目前沒有任何介面讓診所填寫真實名稱。所以就算你現在重新測試,看到的很可能仍是「(未建檔名稱)」或 smoke-clinic 這種無意義字串——症狀跟你原本回報的幾乎一樣,但根因完全不同。

問題 1無法上傳醫師證照照片

回報症狀:找不到/無法使用證照照片上傳功能。

根因:已確認

✔ 這不是 bug,是本機開發環境的預期行為

測試人員回報的原話是「服務尚未開通」、「上傳的功能還沒有開放」。經確認,該測試是在測試人員自己電腦的 localhost 上進行(以 git pull 取得程式碼),因為正式站尚未開放註冊登入。

「服務尚未開通」正是 API 回傳 503 時前端顯示的文案。而本機開發環境的 OBJECT_STORAGE_* 環境變數預設為空,必然觸發 503。這在部署 runbook 中早有明文記載:

15.上線部署整備runbook.md:128
「OBJECT_STORAGE_* 未設的環境(如本機 dev 預設)證照簽章上傳(F1-10)
  回 503,其餘功能不受影響。」

結論:功能本身完全正常,測試人員的本機環境缺少 Cloudflare R2 的連線設定而已。

503 的觸發條件(唯一路徑)

整條判斷鏈非常明確,沒有其他分支會產生這個訊息:

關於 production 的準確表述

這次事件完全沒有涉及 production——測試人員從頭到尾都在 localhost。因此本次回報不構成對正式環境的任何指控。

需要誠實區分的是:production 的 R2 上傳鏈驗證於 2026-07-09,當時全數通過。這是當時的證據,不等於「今天必然仍正常」。若要確認當下狀態,需另行驗證,但目前沒有任何跡象顯示它有問題。

完整實作鏈路

檔案位置職責狀態
前端輸入 libs/ui/src/lib/doctor-profile-form.tsx:709 <input type="file">,accept 白名單 已實作
前端串接 apps/web/app/(doctor)/profile/profile-form.tsx:101–142 取簽章 URL → PUT 檔案 → 回填 documentRef 已實作
API 端點 apps/web/app/api/doctor-profiles/me/license-upload-url/route.ts withRole(['doctor']) 守門 → 產生 presigned PUT URL 已實作
服務層 libs/doctor/src/lib/license-upload.service.ts content-type 白名單 + key 以 accountId 命名空間化 已實作
簽章實作 libs/doctor/src/lib/s3-signed-upload.ts S3 相容簽章(不耦合於 route/service) 已實作
物件儲存 Cloudflare R2 dcm-license-docs 私有桶(匿名 GET 拒絕、r2.dev 已停用) 2026-07-09 開通
e2e 測試 apps/web-e2e/src/m06-license-upload.spec.ts 簽章 200 → PUT 實檔 → documentRef 回存 → 物件實存 已通過

上傳時實際發生什麼事

採用「presigned URL 直傳」設計——檔案不經過平台伺服器,由瀏覽器直接 PUT 到 R2,平台只負責簽發一把限時鑰匙:

如何讓測試人員在本機真的測到上傳

只要補上 5 個環境變數,本機就能完整跑通上傳流程。但這裡有一個不可妥協的安全前提

⚠ 絕對不要把 production 的 R2 金鑰交給測試人員的電腦

dcm-license-docs 這個 bucket 存放的是醫師證照文件=真實個資。把正式環境的存取金鑰散佈到個人電腦上,等同擴大個資外洩面,且違反專案既有的機密管理紀律。

正確作法:另建一個開發專用 bucket(例如 dcm-license-docs-dev),發一組只對該 bucket 有權限的 scoped API token,再把那組值給測試人員。正式桶的金鑰始終不離開 Vercel。

測試人員本機 .env 需補的 5 個鍵(名稱須與 config.ts:45–49 完全一致):

OBJECT_STORAGE_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
OBJECT_STORAGE_BUCKET=dcm-license-docs-dev
OBJECT_STORAGE_ACCESS_KEY=<dev scoped token 的 access key>
OBJECT_STORAGE_SECRET_KEY=<dev scoped token 的 secret>
OBJECT_STORAGE_REGION=auto

補上後重啟 dev server,「服務尚未開通」即會消失。若不想開發環境接雲端,另一個選項是接受本機無法測上傳,把這項驗證留在正式環境進行。

其他可能的上傳失敗路徑(供日後排查參考)

本次事件與以下無關,但正式環境上線後若有真實使用者回報「傳不上去」,可依此對照:

原因使用者看到的訊息備註
檔案格式非 PDF/JPG/PNG 「證照僅限 PDF/JPG/PNG 文件」 iPhone 拍照預設為 HEIC,可能被擋(iOS Safari 有時會自動轉 JPEG,非必然)
沒先按「新增證照」 找不到上傳欄位 上傳控制項在證照列內部
檔案超過 10 MB 「證照檔案上傳失敗」 高解析度照片可能接近上限
簽章 URL 逾時 「證照檔案上傳失敗」 選檔後閒置超過 5 分鐘才實際送出
為什麼限制得這麼嚴?這是刻意的合規設計

只允許 PDF/JPG/PNG、限 10 MB、限時 300 秒、私有桶不可公開讀,是為了守住專案鐵律第 5 條——絕不蒐集特種個資。把上傳欄位限死在文件類型,是為了避免醫師誤傳病歷、健檢報告等敏感資料進入平台。設計註記見 signed-upload.ts:8–11

一項尚未在真實環境驗證的項目

admin 端的簽章下載(讓營運者查驗證照檔案)程式碼已完成(SignedDownloadProviderGET /api/admin/doctor-profiles/:id/licenses/admin/credentials 區塊),但 TODO.md 明載此路徑尚未在具備真實 R2 環境變數的環境跑過一次驗證(本機環境變數為空會自動跳過測試)。也就是說:上傳已實機驗證,下載查驗還沒有。這是一個已知的驗證缺口。

建議真正需要處理的事項

把「已完成」與「實際仍有缺口」分開,避免把已完工的東西重做一遍。

項目判定建議動作
證照上傳功能本體 已完成 不需任何修改。測試人員遇到的 503 是本機缺環境變數,非程式問題
測試人員本機無法測上傳 環境設定 另建 dcm-license-docs-dev bucket + scoped token,把 5 個 env 給測試人員。切勿給正式桶金鑰
撮合 UI 名稱 enrich 管線 已完成 不需重做。四條路徑皆已接上
診所無法填寫真實名稱 真缺口 目前診所名稱永遠是 email 前綴。需補「診所基本資料編輯」介面,或由 admin 後台代填
醫師未建檔即無姓名 體驗缺口 撮合發生時醫師通常已建檔;但可考慮在 admin 頁提示「該醫師尚未完成建檔」
admin 簽章下載未實機驗證 驗證缺口 在具備 R2 環境變數的環境跑一次 m06-license-upload.spec.ts
OBJECT_STORAGE_ENDPOINT 未納入 null 檢查 潛在陷阱 s3-signed-upload.ts:145–152 檢查了另外四把鍵卻獨漏 ENDPOINT。R2 必須指定 endpoint,若僅它缺失/打錯,會建出 provider 並對 AWS 預設端點簽章 → 使用者拿到含糊的「上傳失敗」而非清楚的 503
建議的下一步

回報的兩項都不是缺少功能,因此不需要新的開發工單。真正值得做的只有三件,且都不阻斷目前卡在「等律師回覆」的上線主線:

① 診所真實名稱——唯一的功能性缺口。診所目前永遠只有 email 前綴當名字,且平台沒有任何介面能改。真實診所加入後這會立刻變成 admin 的日常困擾。
② 給測試人員一組 dev 專用的 R2 設定——讓本機能完整測上傳。務必用獨立 bucket + scoped token,不要動正式桶金鑰。
③ 補 ENDPOINT 的 null 檢查——幾行程式的防呆,避免日後設定漏一把鍵時得到含糊的錯誤訊息。

延伸能不能在正式站開通註冊來測試?

關鍵不在「旗標開多久」,而在「誰會註冊」。這條軸線一分開,答案就清楚了。

做法誰會建立帳號判定
用 seed script 直接建測試帳號 只有你們(完全不開放註冊) 最乾淨 · 建議
短暫開旗標 → 測完關回 → 清資料 你們自己(窗口內理論上他人也可) 可行 · 已做過兩次
長期對外開放註冊 真實陌生使用者 被閘門擋住

為什麼「長期對外開放」不行——最核心的一個理由

收到的同意在法律上是無效的

註冊表單的兩個必勾同意項,連結目前都指向 href="#"register-form.tsx:176, 191)——也就是使用者勾選「我已閱讀並同意服務條款」時,根本沒有條款可讀。而三份文件(醫師版/診所版個資告知同意書、服務條款)都還是未經律師定稿的草稿(16 表 2-1/2-2/2-3,皆為 🔒 阻斷項)。

結果就是:你握有真實個資,卻沒有取得有效同意的法律基礎。這比「還有幾個 gate 沒過」嚴重得多,也是整個階段 2 設計出來要防的事。

16 表階段 2 共 8 個 🔒 阻斷項,目前已過 1 項(2-14 Email 驗證,2026-07-17)、尚有 7 項未過

建議作法:用 seed script,完全不必開旗標

你想測的兩件事(證照上傳、admin 名稱顯示)需要的是「帳號存在」,不是「註冊開放」。正式站相對本機唯一多出來的東西,就是設定好的 R2——帳號怎麼來的無所謂。

專案已有 pnpm seed:adminlibs/auth/src/seed/seed-admin.cli.ts)可作為範本,擴充成建立 doctor/clinic 測試帳號的腳本即可。好處是零對外曝光窗口、可重複執行,日後每次要驗正式環境都能用。

若仍選擇短暫開旗標(可行,且有前例)

2026-07-01 與 07-17 都是這樣做的,流程可逆。三個實務注意事項:

  • 窗口要短且有人看著——不要為了讓測試人員慢慢測而開好幾天,那就從「團隊測試」變成「對外開放」了
  • 測完關回旗標並清除測試資料(前兩次都有做,dcm_prod 清到只剩 admin)
  • Email 驗證現在是開的——每個測試帳號都需要一個真實信箱來收驗證信、點連結,否則會被 403 閘門擋住無法刊登/舉手

附錄查證方法與證據基準

本次分析採雙知識圖譜交叉查詢,再回讀真實檔案驗證——圖譜只用於定位,所有結論皆以實際檔案內容與 git 實況為準。