從檔案擷取並匯入資料
情境:你收到一個訂單檔案,想先檢查伺服器推測的欄位,再決定建立新表或寫入既有表。
前置條件
你需要目標 scope 的存取權。Chatroom extraction 使用 room access gate;department extraction 與 preview 只要求部門屬於呼叫者的公司,不要求部門管理權限;company extraction 需要 company-management access,各 scope 的拒絕條件不同;跨公司部門回 404。若選擇既有表,還需要該表的 insert 權限。本例使用聊天室 11111111-1111-4111-8111-111111111111。匯入大小由部署環境的 CUSTOM_TABLE_IMPORT_MAX_BYTES 控制,預設為 50 MiB;所有 private 請求使用使用者 access token。
各 scope 的門檻以各自的 403 detail 文字拒絕。聊天室門檻允許聊天室建立者、聊天室所屬部門的部門管理員,或來自其他部門的公司管理員;只是聊天室成員並不足夠(同部門為 403 Insufficient permissions, not the creator. (Required department manager),其他部門為 403 Insufficient permissions, not at the same department. (Required company manager);自後端 5.21.0 起,其他公司的聊天室現在回 404 Chatroom Not found,與房間不存在的回應相同,不再是 403 Insufficient permissions. (Different Company))。Company 匯入需要 CAN_MANAGE_COMPANY(403 Insufficient permissions for company-level access.),建立部門表格需要部門管理權限(403 Insufficient permissions for department-level access.)。
匯入 session 綁定於 scope,而不是使用者:任何通過同一個 scope 門檻、且持有 sid 的呼叫者都能預覽並提交它。擷取 ticket 與 session 各保留 7,200 秒(2 小時),請在這段期間內完成匯入。與 session 不同,擷取 ticket 只有上傳該檔案的使用者能輪詢(步驟 2)。
部門上傳與寫入權限(v5.10.0)
部門所屬公司的已登入使用者可擷取與預覽 department 匯入 session,不再要求部門管理權限。上傳或預覽不會授予資料表存取權。具有既有表 insert 權限時選擇「匯入既有表」;要「建立新表」仍須部門管理權限。權限撤銷後,後續 map 請求會被拒絕;insert 權限也不代表匯入列的 can_edit 為 true。Session 必須符合請求的部門;不存在、過期或錯誤 scope 都回 404。
後端 #1261 已以真實 staging E2E 驗證兩列 XLSX、僅新增權限的讀回、拒絕建新表與撤權行為;修正已隨 v5.10.0 發佈。
分隔文字擷取
UTF-8 CSV 擷取會用前 1,024 個字元判斷逗號、分號、tab 或直線分隔;判斷失敗就用逗號。只去除開頭 BOM,保留引號內的 LF/CRLF 與原始儲存格文字,worker 位元組上限仍適用。改變的是擷取,不是 v1 僅新增的寫入契約。呼叫同步 v2 loader 前須選取實際 candidate/signature。
步驟
1. 上傳檔案並啟動 extract
將 multipart 欄位 file 送給 import.extract;回應會立刻給 ticket_id,不代表擷取已完成。
curl -X POST \
-H "Authorization: Bearer <user-access-token>" \
-F "file=@orders.csv;type=text/csv" \
"https://api.example.invalid/private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/import/extract"以下假設 ticket 是 44444444-4444-8444-8444-444444444444。
伺服器讀取檔案的方式取決於副檔名:
.csv直接以 UTF-8 讀取。開頭的 byte-order mark 會被去掉;出現在其他位置的 U+FEFF 仍是儲存格資料。分隔符由前 1,024 個字元判斷(判斷失敗時用逗號),帶引號的儲存格會保留內嵌換行與每一個字元,標題名稱會去除前後空白,較短的紀錄會補齊到最寬的那一列。儲存格文字要「完全保留」的唯一例外,是紀錄匯出在疑似公式的文字前面加上的標記:以撇號後接 U+200B 開頭的儲存格或標題,會剛好被去掉這兩個字元,所以匯出檔再匯入可原樣還原,而手動輸入的撇號會保留。明確的空欄位與空白行都會保留為一列空儲存格,因此會計入row_count,而且除非 required 欄位或型別驗證拒絕,否則會被新增為空白紀錄。解析是嚴格的:格式錯誤的檔案、單一儲存格超過解析器的欄位上限,或不是 UTF-8 的檔案,都會讓 extract ticket 失敗(CSV parse failed at line N: …/CSV field exceeds parser limit (N characters))。上傳前請把試算表另存為 CSV UTF-8;Big5 或其他舊編碼的 CSV 無法擷取。.xlsx一般會和其他試算表、文件一樣經過文件轉換器。一般擷取保留儲存格實際值,不以顯示格式四捨五入;常值文字、entity、跳脫的表格分隔符與儲存格內換行會保留。若某欄要成為decimal(見步驟 4),請加上?preserve_text_cells=true:此時會逐格讀取每個工作表,作者輸入的文字會逐位元組保留,任何含有數字、日期或公式儲存格的欄位都會被標記為非文字。這個模式每個工作表最多 100 欄、100,000 筆資料列,而且標題必須唯一且非空;標題相同的工作表會合併為一個候選。對其他副檔名送出此旗標會得到400 preserve_text_cells supports .xlsx files only。- 其他格式(
.xls、PDF 與文件)會經過文件轉換器,每一種不同的表格標題產生一個候選。
分流只看副檔名:只有 .csv 使用 CSV 讀取器,所以 .tsv 或其他任何檔案都會經過轉換器。上傳沒有允許清單——不支援的檔案會先被接受,再於 ticket 中失敗或找不到表格——而且 OCR 關閉,所以掃描的 PDF 或圖片不會產生候選。轉換器會找出 HTML 表格與 Markdown pipe 表格。HTML 表格(含合併儲存格)的標題是第一個至少有兩個非空儲存格的列(單欄表格為一個),colspan 會填入第一欄並讓其他欄留空,rowspan 會把值複製到被涵蓋的列,全空的列會被丟掉。Pipe 表格遇到空白行、沒有 | 的行,或儲存格數不同的列時結束。標題完全相同的表格會合併為一個候選,只有標題的表格不會產生候選。一個檔案中的工作表或候選數量沒有上限。
重複或無名稱的標題會原樣保留,不重新命名;只有 preserve_text_cells 模式會拒絕它們。部署可設定 CUSTOM_TABLE_IMPORT_LLM_NAMING=true:推測 schema 的名稱與說明會使用匯入公司可用的聊天模型。每個匯入工作只選擇一次模型,模型會收到每個欄名與前五個樣本值。沒有可用模型、選擇失敗或命名失敗時,沿用依標題產生的確定性名稱。source_columns 一律保留標題名稱,column_map 的 key 必須使用它們。
舊式 .xls(BIFF)擷取可能將儲存格四捨五入,因此建立含 decimal 欄的資料表或映射至既有 decimal 欄,會在建表或新增資料列之前以 400 {"detail":"Decimal XLS import is unsupported because extraction may round cells; use UTF-8 CSV or text cells in XLSX with preserve_text_cells=true"} 拒絕。請使用 UTF-8 CSV,或以 preserve_text_cells=true 擷取的 .xlsx 文字儲存格。排入佇列的試算表匯入會重新檢查目標的即時 schema:若排入後目標改為 decimal,且來源無法保證精確,ticket 會以 error_code: "custom_table_import_exact_source_required" 結束為 failed,不寫入任何資料列。匯入 worker 使用的 bulk 路徑也遵守這項檢查。
2. 輪詢 extract ticket
import.extract 使用共用非同步 watchdog。輪詢下列精確路徑,直到 body.status 為 completed 或 failed;processing 時依 body.current_step 顯示進度(Task queued...、Downloading file...、Extracting tables...)。
GET /public/task/44444444-4444-8444-8444-444444444444成功時從 body.import_session_id 取出 session ID。Ticket 在排入佇列時帶有這個 ID(那時 session 還不存在),但 worker 寫入的 processing 與 failed body 不帶它,只有 completed 的 body 才再次帶有,所以請等到 completed 再使用。完成的 ticket 也會回報 candidate_count 與 current_step: "Detected N table(s)";檔案中沒有含資料列的表格時,0 也是正常完成。failed 的 ticket 帶有 error_message,內容是原始的失敗文字——例如 CSV parse failed at line N: …、UTF-8 解碼錯誤、XLSX exact-text import exceeds 100 columns、XLSX exact-text import needs unique non-empty headers 或 XLSX exact-text import exceeds 100000 rows per sheet。若 watchdog 的 ttl 是 -1,ticket 內容已不存在或過期;不要猜一個 session ID。Body 也帶有 private: true 與 operation: "custom-table-import-extract"。
只有上傳該檔案的使用者能輪詢擷取 ticket。自後端 5.21.0 起,它的 id 是綁定該使用者的 version-8 UUID,因此同公司的其他使用者、其他公司、沒有或無效的 token,以及同形狀的未知 id,得到的都是同一個 404 {"detail": "Task not found"}。過期的 ticket 對它的擁有者仍會回 ttl: -1,再持續約一天,直到 owner 綁定失效,之後同樣是這個 404(見誰可以輪詢哪種 ticket)。
使用 worker=batch、sqs 或 pubsub 時,提交失敗會回 500(Failed to submit task to AWS Batch: … 或 Failed to submit task to SQS-ECS: …),ticket 會停在 failed;請重新上傳檔案。
3. 檢查 session preview
import.sessionPreview 的 preview 可能包含多個候選表。對你要匯入的候選,保留原樣的 signature,並檢查 source_columns、proposed_schema、sample_rows 與 row_count。
GET /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/import/33333333-3333-4333-8333-333333333333後續請求的 signature 必須來自這個 session;它用來鎖定候選資料,不是讓客戶端重新計算的欄位。sample_rows 是前 20 列,每欄的 samples 是其前 20 個非空值,row_count 則計算所有解析出的列。
4. 寫入前檢查型別相容性
每個擷取出的儲存格送進 row mapper 時都是字串。Preview 的型別推測只是一項建議:它只會提出 string、text、integer、float、date 或 datetime,不會提出 boolean、decimal、任何 list 型別、principal、JSON 或 computed 型別。只有每一個非空值都符合形狀時才會推測該型別:integer 與 float 允許可選的正負號、數字與一個小數點——不含指數,也不含千分位——date 是 YYYY-MM-DD,datetime 再加上空白或 T 與 HH:MM,秒數可有可無。全空欄會推測為 string;只要任一非空值超過 256 個字元,字串欄就會推測為 text。
調整推測 schema 或對應既有表時,請依下表判斷:
| 目標欄位型別 | 檔案匯入行為 |
|---|---|
string、text | 以字串接受,仍須通過目標欄位的一般長度與 required 檢查。 |
integer、float | Record validator 會轉換數字字串;integer 目標會把帶小數的文字四捨六入到最接近的整數,剛好一半時取偶數("2.5" → 2)。格式錯誤或非有限的數字會使該列無效。 |
decimal | 12.50 這類純 decimal 字串會精確儲存,並依欄位的 scale 正規化。千分位符號、貨幣符號、指數表示法、開頭的 + 或前導零、前後空白(CSV 儲存格不會去除空白),以及小數位數多於 scale——即使多出的是零——都會使該列無效。來自 .xlsx 時,除非該 session 以 preserve_text_cells=true 擷取、且來源欄只含文字儲存格,否則 decimal 目標會被拒絕:Decimal XLSX import requires re-extraction with preserve_text_cells=true/Decimal XLSX import requires text-formatted source cells; native numeric cells may already be rounded。 |
boolean | 只有在你明確選擇 boolean 作為目標型別時:mapper 會把 true 與 false 文字(不分大小寫、忽略前後空白)轉成 JSON boolean。其他值——"1"、"yes"、"是"——仍是字串並使該列無效;空儲存格維持空白。 |
date | 只接受 record contract 的標準 YYYY-MM-DD 格式。 |
datetime | 儲存為 YYYY-MM-DD HH:MM。T 分隔符與秒數(可含小數)會被接受並截到分鐘,因此 2026-07-01T09:30:15 會存成 2026-07-01 09:30;09:30:60 這類不可能的時間會使該列無效。 |
select | 字串必須與某個已設定 option 完全相同才可寫入。 |
multi_select | 不相容於一般擷取出的儲存格:record contract 要求陣列,但 importer 只提供一個原始字串。 |
attachment | 儲存格若是一個 attachment(blob)ID,或 ["id-1","id-2"] 這類 ID 的 JSON 陣列,且每個 ID 都是資料表 tenant 擁有的 blob,就會被接受。檔名或以逗號分隔的清單不會被解析,並使該列無效。 |
user、social_client | 字串只有在它是該資料表 tenant 所擁有 principal 的原始 ID 時才可寫入;不會解析名稱或電子郵件地址。 |
principal | 字串只有在它是帶標籤的 cell(user:<id>、smc:<id> 或 room:<id>)、該 principal 屬於資料表 tenant,且 smc:/room: 的聊天室仍存活時才可寫入。裸 ID 會以形狀錯誤被拒,名稱也不會被解析。 |
json | 在派送資料列前即被拒絕為匯入目標。 |
link、rollup、lookup、formula | 會被拒絕為 computed 匯入目標。Create-from-import 完成後再新增這些欄位。 |
Sniffer 只檢查 date 與 datetime 的字串形狀,不檢查日曆日期是否有效。請把 preview 視為建議,派送前先驗證代表性資料。
5. 在建立新表與寫入既有表之間二選一
若檔案代表新的資料集,走 import.createFromImport。schema_definition.columns 必須與候選欄位保持相同數量與順序;可以改名、調整支援的型別與 required 設定,但不能在此新增或刪除位置,也不能建立 link、rollup、lookup 或 formula。這些衍生欄位請在匯入後另行建立。
伺服器以位置對應 create:來源第 i 欄寫入 schema 第 i 欄,與名稱無關。因此來源標題重複或空白都沒問題,只要編輯後的 schema 讓每一欄都有唯一名稱。欄數檢查、decimal 來源檢查,以及拒絕 json 與 computed 型別,都在建立表格之前執行,所以這些 400 不會留下任何東西——舊版會先建立表格,欄數不符時留下空表。
有四種失敗仍發生在表格建立之後,並留下一張空表:skip_invalid: false 因缺少 required 值而停止(422,帶 table_id 與說明)、400 No valid rows to insert after mapping、對應後超過 50,000 筆紀錄(422 {"table_id": …, "error": "Import has more than 50000 mapped records in one bulk insert", "note": "Table was created but no import rows were submitted"}),以及 worker 提交失敗的 500。較大的檔案請先拆分再匯入。
POST /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/import/33333333-3333-4333-8333-333333333333/create
Content-Type: application/json
{
"signature": "a3f5f7d9b1c3e5f709182736455463728190aabbccddeeff0011223344556677",
"table_name": "匯入訂單",
"description": "來自七月 CSV 的訂單",
"schema_definition": {
"columns": [
{
"name": "訂單編號",
"type": "string",
"required": true
}
]
},
"skip_invalid": true
}若資料應追加到現有表,使用 import.mapIntoTable(write_policy 為 commands_only 的資料表會以 403 governed_context_required 拒絕它)。column_map 的 key 是 preview 的來源欄名,value 是既有表的顯示名稱或內部欄位 ID;只能對應可寫入、可見的 stored 欄位,且必須符合上方的相容性表。JSON 與 computed 目標會被拒絕(400 Column '<target>' is a json column and cannot be an import target,computed 欄位則為 … is a <type> column …),未知或隱藏的目標為 400 Unknown target column '<target>'。沒有列入 map 的來源欄不會寫入,而對不到任何來源欄的 column_map key 會被靜默忽略——拼錯的 key 什麼都不會對應,還可能產生空白紀錄。來源欄名重複時,由最後一個同名欄提供值;其他重複欄只是不被使用。對應後超過 50,000 筆紀錄會得到 400 Import has more than 50000 mapped records in one bulk insert,表格被 migration 鎖住時則回 423。
目標表格的每一個 required stored 欄位都必須被對應。Mapper 只拿已對應的值檢查 required 欄位,所以未對應的 required 欄位會讓每一列都失敗——即使該欄有預設值也一樣——最後得到 400 No valid rows to insert after mapping(skip_invalid: false 時則為下方的 422)。
POST /private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/import/33333333-3333-4333-8333-333333333333
Content-Type: application/json
{
"signature": "a3f5f7d9b1c3e5f709182736455463728190aabbccddeeff0011223344556677",
"column_map": {
"order_no": "訂單編號"
},
"skip_invalid": true
}skip_invalid 只控制 row mapper 對「缺少 required 值」的檢查;空白或只有空白字元的儲存格視為缺少。設為 true 時,缺少 required mapped value 的資料列會在 bulk 派送前被省略;目前 import 回應與 ticket 都不會揭露這個 mapper skip 計數。設為 false 時,請求會在第一筆這類資料列停止、不派送 insert,並回 422 指出該列(row 3: missing required column(s) ['col_…'],使用內部欄位 ID)。在 create 分支上,這個 422 是物件 {"table_id": …, "error": "row 3: …", "note": "Table was created but rows failed validation; fix data or use skip_invalid=true"},因為表格已經建立並維持空白。
型別與 schema 驗證之後才在非同步 bulk worker 執行,不受 skip_invalid 影響。原始字串無法滿足目標型別時,會成為 ticket 的逐列錯誤;其他資料列可能已 commit,而 terminal status 是 completed_with_errors。Ticket 錯誤的 index 計算的是派送出去的紀錄(skip_invalid 丟掉的列已排除),不是來源列。寫入前請比較來源 row count 與派送/ticket totals、檢查 ticket errors,並審查 preview、型別相容性與 required 欄位。
這裡的 v1 檔案 map/create 途徑只會新增;有界同步自然鍵 upsert/update-only、原生 link、子列與儲存 mapping 請用匯入 v2。它沒有 upsert——body 中多出的 match_column 會被靜默忽略——也沒有 idempotency key:重複的 map 會再新增一次資料列;重複的 create 只因為表格名稱在每個 scope 內必須唯一才會被拒絕——換一個 table_name 就會建立第二張表。兩個分支匯入的資料列都以匯入者為 created_by,created_by_ai 為 false。Worker 提交失敗為 500;在 create 分支上,新表格此時已經存在並維持空白。
6. 輪詢寫入 ticket
兩個分支都會回傳新的 ticket_id;create 分支還會立即回傳 table_id。對新的 ticket 使用同一個 watchdog:
GET /public/task/ct-bulk-55555555-5555-4555-8555-555555555555寫入 ticket 就是一般的 bulk-insert ticket,在最後一次寫入後保留 24 小時。若它的 worker 消失,一次輪詢會把它終結為 failed,error_code 為 ct.bulk_interrupted、ct.bulk_not_started 或 ct.bulk_timeout,已寫入的列會保留(見工作中止、尚未完成時)。只有在 terminal status 為 completed 時才把流程標為成功。completed_with_errors 要檢查列級錯誤;held_for_approval 表示整批尚未寫入,應接到核准流程;failed 則保留 ticket 回應供診斷。
寫入 ticket 會在 worker 開始時重新檢查匯入者的 insert 授權,而不只在 request 被接受時檢查。若使用者在這段期間失去表格權限、成員資格或帳號,ticket 會以 failed 結束,帶 error_code: "custom_table_bulk_insert_authorization_denied" 與 Insert authorization is no longer available.,且不會寫入任何資料列。見批次操作。
你會看到什麼
extract ticket 完成後會提供 session ID;preview 會顯示候選 schema 與樣本。選擇 create 時會新增一張表,選擇 map 時會保留原表 schema;最終 ticket 會報告實際寫入結果,而不是只代表已排入佇列。
常見錯誤
請直接查看檔案擷取錯誤表、preview 錯誤表、建立新表錯誤表與對應既有表錯誤表。
試試看
檔案匯入流程精靈會保留 extract ticket、session ID 與 signature,並讓你在 create 和 map 分支之間選擇。