醫境媒合平台 · 內部技術分析
問題 1(證照上傳):根因已確認——測試人員在 localhost 測試,本機未設 OBJECT_STORAGE_* 環境變數,回 503。這是 runbook 明文記載的預期行為,不是 bug。功能本身四層全通。
問題 2(名稱顯示):功能已上線,回報畫面為舊版;但診所永遠無法填寫真實名稱——這是唯一真正需要處理的缺口。
回報症狀:撮合記錄頁的「診所彙總」與「醫師」欄位顯示 6a55f496d145549b341c1f60 這類 24 碼 ObjectId,難以辨識是誰。
名稱顯示功能(計畫 29)已於 2026-07-19 由 commit 435b448 實作完成,並隨同一次 push 部署至 production。但目前的實作在自助註冊的帳號上會全面 fallback 回 ID——因為那些帳號的名稱欄位根本是空的或無意義。
function shortId(id: string): string {
return id.length > 6 ? `…${id.slice(-6)}` : id;
}
畫面渲染的是「名稱(或替代文字)+ …41c1f60 尾 6 碼 + 複製按鈕」。完整 ID 只存在於 title 屬性與剪貼簿,永遠不會成為可見內文。回報畫面顯示完整 24 碼、無替代文字、無複製鈕,因此不是這版程式碼的輸出。
對 https://yijingmatch.com/ 取得的 HTML 中 <title> 為「醫境媒合平台」,即品牌變更 commit c75eacd。該 commit 與名稱 enrich 的 435b448 屬同一次 push,且時序在其之後——因此 enrich 必然已一併上線。
TODO.md 與 16.上線前檢查表-簽核.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 欄位,不改變既有回應結構:
enrichMatchRecordViews() 併行查兩張表match-record.service.ts:222–235lookupDoctorNames() 查 DoctorProfile.fullName;lookupClinicNames() 查 Clinic.name,皆帶 deletedAt: null 過濾match-record.service.ts:188–220透過 gitnexus 的 call-graph 確認,enrichMatchRecordViews 的呼叫端確實涵蓋 listMatchRecords、createMatchRecordsForJobPost、updateMatchRecord 三者;診所彙總另走 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 這種無意義字串——症狀跟你原本回報的幾乎一樣,但根因完全不同。
回報症狀:找不到/無法使用證照照片上傳功能。
測試人員回報的原話是「服務尚未開通」、「上傳的功能還沒有開放」。經確認,該測試是在測試人員自己電腦的 localhost 上進行(以 git pull 取得程式碼),因為正式站尚未開放註冊登入。
「服務尚未開通」正是 API 回傳 503 時前端顯示的文案。而本機開發環境的 OBJECT_STORAGE_* 環境變數預設為空,必然觸發 503。這在部署 runbook 中早有明文記載:
15.上線部署整備runbook.md:128
「OBJECT_STORAGE_* 未設的環境(如本機 dev 預設)證照簽章上傳(F1-10)
回 503,其餘功能不受影響。」
結論:功能本身完全正常,測試人員的本機環境缺少 Cloudflare R2 的連線設定而已。
整條判斷鏈非常明確,沒有其他分支會產生這個訊息:
.env 的 OBJECT_STORAGE_* 為空.env.example:34–38(預設即空白)createS3SignedUploadProviderFromEnv() 檢查 BUCKET/ACCESS_KEY/SECRET_KEY/REGION,任一為空即回傳 nulllibs/doctor/src/lib/s3-signed-upload.ts:145–152null → 回 503 SERVICE_UNAVAILABLE「物件儲存尚未配置」app/api/doctor-profiles/me/license-upload-url/route.ts:43–53這次事件完全沒有涉及 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,平台只負責簽發一把限時鑰匙:
/profile 的證照列選擇檔案doctor-profile-form.tsx:708–718POST /api/doctor-profiles/me/license-upload-url,帶 contentTypeprofile-form.tsx:109licenses/{accountId}/{uuid}——以帳號命名空間化,防跨人覆寫與列舉license-upload.service.ts:34documentRefprofile-form.tsx:128–137只要補上 5 個環境變數,本機就能完整跑通上傳流程。但這裡有一個不可妥協的安全前提:
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 端的簽章下載(讓營運者查驗證照檔案)程式碼已完成(SignedDownloadProvider + GET /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 項未過:
AUTH_SECRET/ADMIN_PASSWORD)你想測的兩件事(證照上傳、admin 名稱顯示)需要的是「帳號存在」,不是「註冊開放」。正式站相對本機唯一多出來的東西,就是設定好的 R2——帳號怎麼來的無所謂。
專案已有 pnpm seed:admin(libs/auth/src/seed/seed-admin.cli.ts)可作為範本,擴充成建立 doctor/clinic 測試帳號的腳本即可。好處是零對外曝光窗口、可重複執行,日後每次要驗正式環境都能用。
2026-07-01 與 07-17 都是這樣做的,流程可逆。三個實務注意事項:
dcm_prod 清到只剩 admin)本次分析採雙知識圖譜交叉查詢,再回讀真實檔案驗證——圖譜只用於定位,所有結論皆以實際檔案內容與 git 實況為準。
license-upload.service.ts、signed-upload.ts、計畫 26 批次 3(M-06)等節點enrichMatchRecordViews 的三個呼叫端與兩個被呼叫端,證明四條路徑全接上。註:該索引的全文檢索索引缺失,關鍵字查詢降級,故僅採用精確 symbol 查詢結果檔案:行號 均經實際讀取確認736db3c = origin/main,工作區乾淨yijingmatch.com 取 HTML 確認部署版本