本章目標與前置條件
讀完後,你應能說明為何附件不是一次 POST 完成、owner 資源如何授權,以及 bytes、metadata、outbox 如何保持一致。前置條件是能閱讀 HTTP streaming、UUID、SHA-256 與 PostgreSQL transaction。
核心概念
附件由宿主資源授權
附件沒有獨立的 attachment.read 或 attachment.write permission point;internal/route/testdata/registry.golden
中五條附件路由都是 auth_only,實際判定在 service。owner_type 加 owner_id 由 owner registry 解析宿主資源,讀取使用宿主的
ReadPoint,建立、上傳與 revoke 使用 WritePoint。判定順序固定:未知 id 先回 404,接著判權限(403,不論狀態),
最後才看狀態(409),讓無權者無法推測附件是否存在。這能讓表單、員工 avatar 等不同資源共用儲存生命週期,
又不會把附件授權變成一套平行規則。
目前在 internal/wiring/attachment.go 註冊兩種 owner:
hr.employee:ReadPoint 為hr.employee.read(high risk)、WritePoint 為hr.employee.update;TargetsEmployee讓 employee 範圍生效;已離職員工不能再新增附件。form.instance:ReadPoint/WritePoint 為workflow.run.read/workflow.run.update(皆 high risk);Resolve為空操作,存在性與可見性由表單服務的Authorize判定,寫入只允許申請人在草稿或退回狀態進行;revoke 經WithRevoke在表單 owner lock 內重查,已被歷次送出引用的附件回 400/10602。
兩段式流程
| 階段 | API/內部動作 | 主要狀態 |
|---|---|---|
| 建立意圖 | POST /v1/attachments,檢查 owner、檔名與媒體類型語法,落 metadata 與 upload_expires_at | pending |
| 上傳內容 | PUT /v1/attachments/:id/content,claim、串流量測 size/SHA-256、finalize immutable key | uploading → stored |
| 讀 metadata | GET /v1/attachments/:id | 任何保存的 metadata state |
| 讀 bytes | GET /v1/attachments/:id/content,只允許 stored | streaming download |
| 撤銷 | DELETE /v1/attachments/:id,row 先 revoke 並發 outbox | revoked → deleting → deleted |
上傳期限 UploadTTL 固定 15 分鐘(程式常數,不是設定);超過 upload_expires_at 後 PUT 無法 claim,回
409/10600。worker 的 attachment_reaper job 每分鐘把逾時的 pending/uploading row 標成 failed。資料表不硬刪;
revoke 之後由 worker 先把 row 轉為 deleting、刪除 staging 與 final bytes,再轉為 deleted。
已知缺口:失敗上傳的 staging bytes
另外,超過大小或儲存錯誤而轉為 failed 的上傳,以及 checksum 衝突轉為 quarantined 的上傳,都不會發出清理事件;
reaper 只掃描 pending/uploading(db/queries/attachments.sql 的 ListExpiredAttachmentUploads),而
docs/infra/14-object-storage.md 描述的 orphan scan 尚未實作。目前唯一會真正刪除 bytes 的路徑是
DELETE /v1/attachments/:id:pending、uploading、stored、quarantined、failed 都能 revoke,worker 會刪除 staging
與 final key。
功能清單與目前狀態
- POST /v1/attachments:已實作(原始碼層級),closed body 欄位為 owner_type、owner_id、file_name、declared_type。
file_name最多 255 字,不得含控制字元或路徑分隔字元;declared_type只驗證type/subtype語法(最多 128 字),沒有上傳類型白名單。成功回 201 與 pending row。 - PUT /v1/attachments/:id/content:已實作(原始碼層級),不走一般 JSON body limit(
HTTP_MAX_BODY_BYTES),由 service 依ATTACHMENT_MAX_BYTES限制大小:超過回 413/10413,row 轉為 failed。request 的 Content-Type 會被忽略,以第一段的declared_type為準;讀寫 deadline 依ATTACHMENT_STREAM_TIMEOUT逐段延長。成功回 200 與id、state、byte_size、sha256、stored_at。 - GET /v1/attachments/:id/content:已實作(原始碼層級),回 Content-Disposition: attachment、nosniff、private no-store 與 ETag(值為 sha256)。Content-Type 只有在 declared type 屬於下載白名單(PDF、PNG、JPEG、GIF、WebP、純文字、CSV、docx、xlsx、pptx、zip)時沿用,其餘一律為
application/octet-stream。 - GET/DELETE /v1/attachments/:id:metadata 在任何狀態都可讀;revoke 回 204,已是 revoked、deleting 或 deleted 時再呼叫仍回 204,不會重複發刪除請求。前端目前沒有呼叫這兩條路由。
- Form attachment hook:features/forms/api.ts 的
useUploadFormAttachment先 POST metadata(ownerType: 'form.instance'),再 PUT raw content;開發 fixture 模式(NODE_ENV=development且NEXT_PUBLIC_MSW=true)不呼叫 API,直接回傳假的 stored 結果。 - Employee avatar:employeeProfileApi.ts 先驗證圖片(PNG/JPEG、5 MiB、4096 px),再建立 attachment、上傳 bytes,最後另行綁定 profile。後端在 HR profile 綁定
avatar_attachment_id時以AvatarVerifier重驗 owner、stored、大小、sha256 與可解碼的 PNG/JPEG,不符回 422/10430(invalid_attachment)。EmployeeAvatarPreview經 GET content 讀取頭像。 - Owner registry/RLS/outbox deletion:backend 已有 service、migration、worker;實際 object provider 需環境核實。原始碼中沒有病毒或惡意檔案掃描。
儲存後端、設定與錯誤碼
- 物件儲存由
OBJECT_STORE_PROVIDER顯式指定(必填):sftpgo、local或memory;staging/production 必須是sftpgo,local/memory只供開發與測試。沒有 S3、MinIO 或 GCS adapter,也沒有 presigned URL:bytes 一律經 API 的 PUT/GET content 代理。 - 相關設定:
OBJECT_STORE_ENDPOINT、OBJECT_STORE_BUCKET、OBJECT_STORE_USERNAME、OBJECT_STORE_PASSWORD(sftpgo);OBJECT_STORE_DIR(local);ATTACHMENT_MAX_BYTES(預設 26214400,即 25 MiB,範圍 1–1073741824);ATTACHMENT_STREAM_TIMEOUT(預設5m)。 - 物件 key 由 server 產生、不含檔名:final key 為
tenants/<tenant_id>/attachments/<owner_type>/<owner_id>/<attachment_id>/<sha256>,staging key 為tenants/<tenant_id>/attachments/<owner_type>/<owner_id>/.staging/<attachment_id>,row 另存storage_root。docs/infra/14-object-storage.md§2.2 的 key 佈局與程式不一致,以internal/service/attachment/content.go為準。 - 錯誤碼:未知 id 為 404/10050;沒有宿主權限為 403;409/10600
attachment_not_ready(下載非 stored、並發第二次 PUT、逾時或已離開 pending 後 PUT);409/10601attachment_checksum_mismatch(row 轉為 quarantined);400/10602attachment_owner_invalid(owner_type 未註冊或 owner 無法解析);503/10603object_store_unavailable;413/10413(超過ATTACHMENT_MAX_BYTES)。
實際請求鏈
feature hook
→ POST /v1/attachments (JSON metadata)
→ owner registry lookup → host WritePoint decision (+ owner Authorize) → owner Resolve
→ tenant transaction INSERT pending row (upload_expires_at = now + 15m)
→ PUT /v1/attachments/:id/content (raw bytes)
→ claim pending → stage → measure → finalize immutable key
→ tenant transaction MarkStored
→ GET content / host resource binding下載時順序是先讀 row、授權 owner、檢查 stored,再開 object store;撤銷後即使 bytes 尚未刪除,也不能再下載。
開發步驟
- 先定義宿主資源的 owner_type、
Resolve(必填)、ReadPoint 與 WritePoint,視需要加上TargetsEmployee、actor-aware 的Authorize與WithRevoke,並在internal/wiring/attachment.go的NewAttachmentOwners註冊;ReadPoint/WritePoint 必須由既有 route 宣告,API 啟動時的owners.Validate會擋下未宣告的 point。 - 於 db/migrations 新增表或欄位時保留 tenant_id、FORCE RLS、state CHECK、owner/expiry index,禁止硬刪權限。
- 讓 phase one 只建立 metadata 與期限;不要把 client 宣稱的 byte_size、sha256 或 storage key 寫入資料庫。
- phase two 以 bounded reader 串流,實測 size/SHA-256,成功後才寫 stored。
- 針對 checksum conflict、store outage、超過大小、重複 upload、撤銷 race 補狀態機測試。
- 新增前端 consumer 時,等待 POST 成功再 PUT,並在 15 分鐘期限內完成上傳;取消/重試要以 attachment ID 隔離,並在宿主綁定成功前不要顯示已完成。
- 撤銷只在 tenant transaction 發出 delete request,bytes 清理交給 internal/worker/attachment.go;逾時或失敗的上傳目前也只能靠 revoke 清掉 staging bytes。
預期結果與驗證
正常流程的預期結果是:metadata row 先出現 pending,內容上傳成功後變成 stored,回應包含 server 計算的 byte_size、sha256 與 stored_at;下載只有在 stored 可讀;revoke 後下載立刻被拒絕, worker 最終把 bytes 與 row 收斂到 deleted。
驗證清單:
- 以同一 tenant 的合法 owner 做 create → upload → metadata → content read。
- 以沒有宿主 WritePoint 的帳號驗證 create/upload 回拒絕,且不洩漏 row state。
- 送入超過 ATTACHMENT_MAX_BYTES 的串流,確認回 413/10413、row 轉 failed。
- 改變 checksum 或模擬 object store conflict,確認回 409/10601、row 進 quarantined,不當作 stored。
- 重放 revoke/worker event,確認 idempotent,不重複刪除或發送。
- 檢查 tenant A 不能從 tenant B 的 attachment UUID 讀 metadata 或 bytes。
- 讓上傳逾時並等 reaper 執行,確認 row 轉 failed;目前 staging bytes 仍會殘留、清理事件進 dead letter(已知缺口),不要當成清理完成。
既有自動化:tests/integration/postgres/attachment_lifecycle_test.go(真 PostgreSQL 搭配 memory object store:完整生命週期與 worker 刪除、並發 PUT 只有一個成功、大小上限轉 failed、宿主 point 經真實 engine 判定)、tests/integration/postgres/attachment_store_test.go(條件式狀態轉換、租戶隔離)、tests/e2e/attachment_routes_test.go(組合環境:上傳、下載 header、revoke 立即不可讀、獨立 worker 收斂為 deleted),以及 internal/service/attachment/*_test.go、internal/worker/attachment_test.go。清單 1–6 都已有單元或整合案例;第 7 項的 handler 接手路徑沒有測試。本輪沒有執行真實 object store、後端 API 或瀏覽器上傳。
常見錯誤
- 只呼叫 POST metadata 就把附件當成已儲存;此時仍是 pending。
- 把 owner_id 當成可直接查詢的外鍵;它是由 registry resolver 驗證的 polymorphic reference。
- 將檔名放進 storage key,造成覆寫、路徑注入或無法 immutable finalize。
- 讓下載在 state check 前先開 object store,撤銷資料可能短暫可讀。
- 用全域 HTTP body limit 截斷串流,卻沒有轉成清楚的 attachment error。
- 前端 avatar 綁定先於 stored attachment;profile CAS 應在 bytes 已確認後執行。
- 以 fixture 或單元測試說明清理 worker 已在部署環境完成。
- 以為 reaper 發出清理事件就代表 staging bytes 已刪除;目前
failedrow 的清理事件會進 dead letter。 - 以為
declared_type有類型白名單;上傳端只驗語法,要限制類型需由宿主(例如 avatar 的AvatarVerifier)或前端把關。
相關文件
- Repository 與資料存取:tenant transaction、migration 與 RLS。
- 租戶上下文與 RLS:附件表的隔離底線。
- 組織與員工:employee avatar 的宿主資源與前端使用者流程。
- 表單與流程:form attachment hook 與工作流欄位。
- 測試與交付:source-level、整合與部署驗收的界線。