附件 Blob 與 multipart 生命週期
Attachment cell 不直接接收檔案 bytes。前端先完成 resumable multipart upload,拿到 blob.id,再把這個 ID 寫進資料列的 attachment 欄位;下載時則用表、列與 blob 三者重新授權後,在名為 signed_url 的舊欄位取得儲存的存取網址。
資料列不是放 bytes 的地方
一筆資料列的整份 data JSON 有上限。每次寫入都會以實際要儲存的 dict(compact JSON、UTF-8)對照 CUSTOM_TABLE_MAX_RECORD_BYTES——預設 1 MiB,可依部署覆寫——超過就以 413 拒絕:
{
"detail": {
"error": "record_too_large",
"message": "Record data is 1310720 bytes; the limit is 1048576 bytes",
"limit": 1048576,
"size": 1310720
}
}在量測大小之前,還有另一道守門會以 400 拒絕任何欄位裡的行內二進位:
{
"detail": {
"error": "inline_binary_not_allowed",
"column": "col_44444444_4444_4444_8444_444444444444",
"reason": "data-url",
"message": "Cell 'col_44444444_4444_4444_8444_444444444444' holds inline binary/base64 content (data-url); upload the file as an attachment (attachment column or the blob API) and store its reference instead"
}
}column 是該格的內部欄位鍵值,因為這道守門檢查的是已儲存(以內部鍵值為鍵)的資料;請用資料表的 column_mapping 對回顯示名稱。 順序是刻意的:貼上 2 MB 的 data URL 會拿到指出解法的 400,而不是單純的 413。
| 項目 | 契約 |
|---|---|
| 適用範圍 | 單筆建立/更新、bulk insert/update 與 mixed create/update action、command insert/update 步驟、歷史還原、測試資料插入,以及 publish 前置檢查。schema migration 對既有列的改寫刻意豁免。 |
| 量測對象 | 合併後、實際要儲存的 dict,不是 request body。更新會量合併後的結果。 |
| 更新拒絕的是變大,不是大 | 已經超過上限的資料列仍可編輯:維持原大小或變小一定通過,拒絕時會附上 previous_size。 |
| 需審核的寫入 | 檢查在送進暫存之前執行,所以過大的受審寫入在送出當下就被拒,不會等到核准後套用才失敗。 |
| 非同步 bulk lane | 拒絕會成為該列的 ticket 錯誤,整張 ticket 不會失敗。 |
| Agent 工具 | 413 對應到終止性的 record_too_large 結果,模型會縮小 payload,而不是把它當「internal error」重試。 |
什麼算行內二進位
偵測刻意做得很窄,因為一般租戶資料本來就會有很長的不透明字串。
reason | 觸發條件 |
|---|---|
data-url | 略過開頭空白後,以不分大小寫方式比對到開頭的 data: 與前 256 個字元內的 ;base64,——不論長度。 |
data-url-embedded | 文字或標記中出現不分大小寫的 ;base64,,其後至少接 1 024 個 base64 字元。 |
base64-run | 至少 4 096 個 base64 字元,且同時含大寫、小寫與數字;若含換行,還必須符合 MIME/PEM 折行形狀(除最後一行外每行等寬,且至少 60 字元)。 |
base64-magic | 至少 1 024 個 base64 字元,同時含大寫、小寫與數字,且以已知檔案 magic 前綴開頭(PNG、JPEG、PDF、ZIP、GIF、WebP、MP3、MP4、HEIC、MOV、OGG、gzip、TIFF、BMP…)。 |
只要出現空白或任何不在 base64 字母表內的字元,純連續串就會中斷——所以一般文字、URL 與 JSON 文字不會被當成連續串命中;內嵌 data URL 仍可能命中上面的獨立規則。貼上的 id、數字、十六進位與 DNA 清單也不會:純連續串必須同時含大小寫和數字。
更新時只檢查有變動的欄位,且變動欄位內部與既有值相同的葉字串會被略過。因此原樣送回既有的二進位欄位永遠不會擋住編輯,只有新加入的二進位葉節點才會被拒絕。
不可變輸入是不同契約
已合併的 CT package/input 基礎使用 JWT、帶 scope ID 的 /core/input-artifacts,不是下方 attachment blob 路由。Upload 用 multipart metadata 加 file;promotion 只把目前可見、自己 turn 的 attachment 複製至目的 manager audience,不接受任意 URL 或一般 blob ID。IaC document 上限 5 MiB,其他 input purpose 1 MiB,每次 byte-read chunk 64 KiB;取得 input 不授予 snapshot/rebuild/restore 權限。
路徑是 id-less scope mount
附件 router 分別掛在三個 scope name 下,但路徑不帶 scope ID:
/private/module/custom_tables/chatroom/tables/{table_id}/blobs/...
/private/module/custom_tables/department/tables/{table_id}/blobs/...
/private/module/custom_tables/company/tables/{table_id}/blobs/...即使是聊天室/部門表,也不要插入 chatroom_id 或 department_id。三個前綴中的任何一個都會到達同一個處理器、處理任何一張表——前綴不會被驗證,tenant 與有效權限完全由 table_id 決定——所以照慣例使用該表自己的 scope 名稱即可。Blob 路由不接受 acting_chatroom_id:全部 15 個操作——五條路由乘以三個掛載點——已於 2026-07-29 移除該參數,包含 company 掛載點,其 422 acting_chatroom_id is not applicable to a company-scoped table 現在不可能被觸發。受治理的部門表,其 channel floor 是從呼叫者自己被授權的房間解析出來的,見 union 契約。
生命週期總覽
init ─→ upload chunk 0..N-1 ─→ status ─→ complete ─→ blob.id 寫入 record
└──────────────────────────→ abort
record + blob.id ─→ download endpoint ─→ signed_url ─→ 下載 bytes1. Init
POST .../tables/{table_id}/blobs/multipart/init 接受 form 欄位(multipart/form-data 或 application/x-www-form-urlencoded;JSON body 會得到 422):filename、file_size、content_type 與可選 chunk_size(5 MiB–200 MiB,預設 5 MiB)。它需要表上的 insert 或 edit 任一寫入能力。
curl -X POST "$BASE/private/module/custom_tables/chatroom/tables/22222222-2222-4222-8222-222222222222/blobs/multipart/init" \
-H "Authorization: Bearer $TOKEN" \
-F "filename=order-note.txt" \
-F "file_size=18" \
-F "content_type=text/plain"回應提供 opaque session_id、chunk_size、total_chunks 與約 24 小時後的 expires_at。Init 會先做全域大小(50 GiB)、table-level 大小與 MIME 檢查:大小採已宣告上限中的最大值,MIME 採已宣告 allowed_mime_types 清單的聯集;若任一 attachment 欄在該維度不設限,init 也不會在該維度提早拒絕。把 blob ID 寫入特定 cell 時,仍會以目標欄的數量、MIME 與大小上限做權威驗證。
2. Upload chunks 與 status
每一塊都送到共用 public leg:
POST /public/module/etl/multipart/chunk它沒有 user-auth dependency,因此持有 session_id 就是能替該 session 新增 parts 的 capability,不只是搭配 bearer token 的一般識別值。不要寫入 log、分析事件或可分享 URL。multipart/form-data 欄位為:必填 session_id、必填 zero-based 整數 chunk_index、必填 binary file field chunk,以及選填 chunk_hash(原始 chunk bytes 的小寫 SHA-256)。每個非最後 part 必須剛好是 chunk_size bytes;最後 part 必須剛好是 file_size - (chunk_index * chunk_size) bytes。
成功回傳 session_id、接受的 chunk_index、zero-based uploaded_chunks、total_chunks、progress_percent 與 is_complete。Index/exact size/hash 無效是 400;session 不存在或過期是 404;part 已上傳或衝突是 409;storage 拒絕大小是 413;cache/storage 失敗或 session reset 是 503(另可能有 storage-policy 403)。這些 public chunk errors 與 private status、complete、abort 對目前使用者的 table/session authorization 是兩個不同邊界。
斷線後呼叫 GET .../multipart/status?session_id=...,比較 uploaded_chunks 與 missing_chunks,只重送缺少的 zero-based indexes。Status 與後續操作會驗證 session 同時屬於目前使用者與目前表,不能拿另一張表或 ETL 的 session 混用。
3. Complete 或 abort
所有 chunks 上傳完成後,以 multipart/form-data 的 session_id 呼叫 POST .../multipart/complete。成功回應刻意遮蔽儲存 URL,只回 blob metadata:
{
"blob": {
"id": "44444444-4444-4444-8444-444444444444",
"filename": "order-note.txt",
"content_type": "text/plain",
"url": null,
"size_bytes": 18,
"created_at": "2026-07-19 02:35:00"
}
}Complete 只建立 blob,不會自動附加到任何資料列。接著把 ID list 寫入 attachment 欄:
{
"附件": ["44444444-4444-4444-8444-444444444444"]
}若使用者取消,上傳尚未 complete 時可呼叫 DELETE .../multipart/abort?session_id=...。Abort 是 terminal;同一 session 不能再 complete,請重新 init。Complete 也會結束該 session。
complete 遇到鎖衝突現在回可重試的 409,而且會保住你的上傳。以前寫入 blob 資料列時遇到暫時性資料庫衝突會走進補償路徑——已完整上傳的物件被刪掉、session 被銷毀、呼叫端拿到 500,只能整個檔案重傳。現在會先辨識衝突:物件與 session 都保留(session 維持 24 小時 TTL),回應是併發合約裡標準的可重試 409。拿同一個 session_id 重呼 complete 就能收尾。complete 對自己的重試也具冪等性:若儲存層在上一次嘗試已經組裝好物件,重試會驗證後直接回傳 blob,而不是拿著已被消耗的 upload id 失敗。另外,完成的上傳會記錄從儲存端量測到的組裝後 size_bytes,而不是 init 時宣告的大小;若量測失敗,會略過上限複查,blob 以 size_bytes: null 建立。組裝或儲存時若發生鎖衝突以外的失敗,仍會走補償路徑——回 500 Failed to complete upload and store file、中止儲存端上傳並刪除 session——只能從 init 重新開始。
4. Download
不要保存 complete 前的 storage URL,也不要直接從 blob metadata 拼 URL。每次下載都呼叫:
GET .../tables/{table_id}/records/{record_id}/attachments/{blob_id}/download伺服器會確認呼叫者能讀該列、attachment 欄沒有被隱藏、blob 確實出現在該列、且 tenant 相符。資料列、欄位、引用或 tenant 的任何失敗都回 404。資料表層級的拒絕更早由讀取門檻發出:資料表不存在、已在垃圾桶(同公司的聊天室資料表,所屬聊天室已刪除時是 Chatroom Not found),或(自後端 5.21.0 起)屬於其他公司時是 404 CustomTable Not found,資料表的 scope 門檻拒絕時是 403(Not a member of this chatroom.、Insufficient permissions for department-scoped table.),沒有讀取權時也是 403(Read access denied for this table.)。detail 會指出失敗的階段——CustomTableRecord Not found(資料列不存在或在垃圾桶中)、Record not found(資料列被列 ACL 或頻道 floor 隱藏)、Attachment not found on this record(blob 不在可見的 attachment 欄中)、Attachment not found(其他 tenant)——而被隱藏的欄位與沒被引用的 blob 共用同一個 body,所以無法藉此探測隱藏欄位。附件路由使用資料列的權限階梯,而不是 by-id 中繼資料門檻:同公司但沒有權限的呼叫者在這裡拿到 403,即使 GET .../tables/{table_id} 回的是 404。雖然舊欄位名叫 signed_url,這條 route 實際上原樣回傳儲存的 blob.url,不會在此進行密碼學簽章或加上時效限制。expiration_seconds 是 0,expires_at 只是建構回應的時間,不能當成可用的到期訊號。請把網址視為敏感資訊,不要保存或分享;每次使用者主動下載時重新請求,也不要假設後續的權限變更——或把 blob id 從儲存格移除、刪除資料列、永久刪除資料表——會撤銷已回傳的網址:自訂資料表的寫入從不刪除 blob 或其儲存物件,完成後從未被掛上的 blob 也同樣會保留。
可靠上傳的前端狀態
至少保存 session_id、chunk_size、total_chunks、expires_at 與本地檔案 fingerprint。重新整理頁面後先查 status;session 過期或 abort 才重新 init。expires_at 固定為 init 後 24 小時,是不帶 UTC 時差的伺服器時鐘時間。它是分塊上傳的硬性期限:之後送出的分塊會得到 404 Upload session has expired,伺服器也會捨棄該 session。Status、complete 與 abort 不比對 expires_at——只要 session 鍵值還在就能運作,且每接受一個分塊,該鍵值的 24 小時 TTL 就會重新計算——但仍請在 expires_at 之前完成 complete:每個分塊的 part 紀錄從上傳起只保留 24 小時,之後才 complete 可能得到 500 Part N data not found。Complete 成功但 record write 失敗時,保留 blob.id 並重試資料列寫入,不要重傳整個檔案。
完整 endpoint 錯誤見附件參考,attachment cell 限制見附件欄位,可操作流程見附件指南與 API Playground。