搜尋開發指南

試試「多租戶」、「啟動」或「outbox」。
搜尋僅使用本站文件,不會傳送至外部服務。

文件目錄

NEXUSPRO / HANDBOOK

附件與兩段式物件儲存

以 owner 授權、pending 狀態、串流上傳與 outbox 清理,理解 NexusPro 附件的完整生命週期。

內容核對 2026-09-28·圖文指南

本章目標與前置條件

讀完後,你應能說明為何附件不是一次 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_atpending
上傳內容PUT /v1/attachments/:id/content,claim、串流量測 size/SHA-256、finalize immutable keyuploading → stored
讀 metadataGET /v1/attachments/:id任何保存的 metadata state
讀 bytesGET /v1/attachments/:id/content,只允許 storedstreaming download
撤銷DELETE /v1/attachments/:id,row 先 revoke 並發 outboxrevoked → 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/10601 attachment_checksum_mismatch(row 轉為 quarantined);400/10602 attachment_owner_invalid(owner_type 未註冊或 owner 無法解析);503/10603 object_store_unavailable;413/10413(超過 ATTACHMENT_MAX_BYTES)。

實際請求鏈

text
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 尚未刪除,也不能再下載。

開發步驟

  1. 先定義宿主資源的 owner_type、Resolve(必填)、ReadPoint 與 WritePoint,視需要加上 TargetsEmployee、actor-aware 的 Authorize 與 WithRevoke,並在 internal/wiring/attachment.go 的 NewAttachmentOwners 註冊;ReadPoint/WritePoint 必須由既有 route 宣告,API 啟動時的 owners.Validate 會擋下未宣告的 point。
  2. 於 db/migrations 新增表或欄位時保留 tenant_id、FORCE RLS、state CHECK、owner/expiry index,禁止硬刪權限。
  3. 讓 phase one 只建立 metadata 與期限;不要把 client 宣稱的 byte_size、sha256 或 storage key 寫入資料庫。
  4. phase two 以 bounded reader 串流,實測 size/SHA-256,成功後才寫 stored。
  5. 針對 checksum conflict、store outage、超過大小、重複 upload、撤銷 race 補狀態機測試。
  6. 新增前端 consumer 時,等待 POST 成功再 PUT,並在 15 分鐘期限內完成上傳;取消/重試要以 attachment ID 隔離,並在宿主綁定成功前不要顯示已完成。
  7. 撤銷只在 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。

驗證清單:

  1. 以同一 tenant 的合法 owner 做 create → upload → metadata → content read。
  2. 以沒有宿主 WritePoint 的帳號驗證 create/upload 回拒絕,且不洩漏 row state。
  3. 送入超過 ATTACHMENT_MAX_BYTES 的串流,確認回 413/10413、row 轉 failed。
  4. 改變 checksum 或模擬 object store conflict,確認回 409/10601、row 進 quarantined,不當作 stored。
  5. 重放 revoke/worker event,確認 idempotent,不重複刪除或發送。
  6. 檢查 tenant A 不能從 tenant B 的 attachment UUID 讀 metadata 或 bytes。
  7. 讓上傳逾時並等 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 已刪除;目前 failed row 的清理事件會進 dead letter。
  • 以為 declared_type 有類型白名單;上傳端只驗語法,要限制類型需由宿主(例如 avatar 的 AvatarVerifier)或前端把關。

相關文件

內容來源與核實範圍

以下路徑相對於所列業務倉庫;核實層級為「已讀原始碼」,不是本輪業務測試或部署驗收。

  • nexus-pro-be-plus/internal/api/v1/attachments.go
  • nexus-pro-be-plus/internal/service/attachment/service.go
  • nexus-pro-be-plus/internal/service/attachment/registry.go
  • nexus-pro-be-plus/internal/domain/attachment.go
  • nexus-pro-be-plus/internal/worker/attachment.go
  • nexus-pro-be-plus/internal/wiring/form_attachments.go
  • nexus-pro-be-plus/db/migrations/000031_attachments.sql
  • nexus-pro-be-plus/docs/infra/14-object-storage.md
  • nexus-pro-web-plus/features/forms/api.ts
  • nexus-pro-web-plus/features/hr-employees/employeeProfileApi.ts
  • nexus-pro-web-plus/features/hr-employees/employeeAvatarRead.ts
  • nexus-pro-be-plus/internal/service/attachment/content.go
  • nexus-pro-be-plus/internal/service/attachment/avatar.go
  • nexus-pro-be-plus/internal/wiring/attachment.go
  • nexus-pro-be-plus/db/queries/attachments.sql
  • nexus-pro-be-plus/api/config-reference.md
  • nexus-pro-be-plus/tests/integration/postgres/attachment_lifecycle_test.go
  • nexus-pro-be-plus/tests/e2e/attachment_routes_test.go
  • nexus-pro-web-plus/features/hr-employees/EmployeeAvatarPreview.tsx
NexusPro 開發指南以程式碼為準 · 以驗證為據