diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index 745be274f..528e075c3 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -5,16 +5,11 @@ package drive import ( "context" - "encoding/json" - "errors" "fmt" - "net/http" "os" "path/filepath" "strings" - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -33,7 +28,7 @@ var DriveImport = common.Shortcut{ }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md)", Required: true}, + {Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md; large files auto use multipart upload)", Required: true}, {Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true}, {Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"}, {Name: "name", Desc: "imported file name (default: local file name without extension)"}, @@ -53,19 +48,15 @@ var DriveImport = common.Shortcut{ FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), } + fileSize, err := preflightDriveImportFile(&spec) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } dry := common.NewDryRunAPI() - dry.Desc("3-step orchestration: upload file -> create import task -> poll status") + dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status") - dry.POST("/open-apis/drive/v1/medias/upload_all"). - Desc("[1] Upload file to get file_token"). - Body(map[string]interface{}{ - "file_name": spec.SourceFileName(), - "parent_type": "ccm_import_open", - "size": "", - "extra": fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension()), - "file": "@" + spec.FilePath, - }) + appendDriveImportUploadDryRun(dry, spec, fileSize) dry.POST("/open-apis/drive/v1/import_tasks"). Desc("[2] Create import task"). @@ -84,13 +75,9 @@ var DriveImport = common.Shortcut{ FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), } - - // Normalize and validate the local input path before opening the file. - safeFilePath, err := validate.SafeInputPath(spec.FilePath) - if err != nil { - return output.ErrValidation("unsafe file path: %s", err) + if _, err := preflightDriveImportFile(&spec); err != nil { + return err } - spec.FilePath = safeFilePath // Step 1: Upload file as media fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType) @@ -151,6 +138,72 @@ var DriveImport = common.Shortcut{ }, } +func preflightDriveImportFile(spec *driveImportSpec) (int64, error) { + // Keep dry-run and execution aligned on path normalization, file existence, + // and format-specific size limits before planning the upload path. + safeFilePath, err := validate.SafeInputPath(spec.FilePath) + if err != nil { + return 0, output.ErrValidation("unsafe file path: %s", err) + } + spec.FilePath = safeFilePath + + info, err := os.Stat(spec.FilePath) + if err != nil { + return 0, output.ErrValidation("cannot read file: %s", err) + } + if !info.Mode().IsRegular() { + return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath) + } + if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil { + return 0, err + } + return info.Size(), nil +} + +func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) { + extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType) + if err != nil { + extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension()) + } + + if fileSize > maxDriveUploadFileSize { + dry.POST("/open-apis/drive/v1/medias/upload_prepare"). + Desc("[1a] Initialize multipart upload"). + Body(map[string]interface{}{ + "file_name": spec.SourceFileName(), + "parent_type": "ccm_import_open", + "parent_node": "", + "size": "", + "extra": extra, + }) + dry.POST("/open-apis/drive/v1/medias/upload_part"). + Desc("[1b] Upload file parts (repeated)"). + Body(map[string]interface{}{ + "upload_id": "", + "seq": "", + "size": "", + "file": "", + }) + dry.POST("/open-apis/drive/v1/medias/upload_finish"). + Desc("[1c] Finalize multipart upload and get file_token"). + Body(map[string]interface{}{ + "upload_id": "", + "block_num": "", + }) + return + } + + dry.POST("/open-apis/drive/v1/medias/upload_all"). + Desc("[1] Upload file to get file_token"). + Body(map[string]interface{}{ + "file_name": spec.SourceFileName(), + "parent_type": "ccm_import_open", + "size": "", + "extra": extra, + "file": "@" + spec.FilePath, + }) +} + // importTargetFileName returns the explicit import name when present, otherwise // derives one from the local file name. func importTargetFileName(filePath, explicitName string) string { @@ -174,73 +227,3 @@ func importDefaultFileName(filePath string) string { } return name } - -// uploadMediaForImport uploads the source file to the temporary import media -// endpoint and returns the file token consumed by import_tasks. -func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { - importInfo, err := os.Stat(filePath) - if err != nil { - return "", output.ErrValidation("cannot read file: %s", err) - } - fileSize := importInfo.Size() - if fileSize > maxDriveUploadFileSize { - return "", output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileSize)/1024/1024) - } - - fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize)) - - f, err := os.Open(filePath) - if err != nil { - return "", err - } - defer f.Close() - - ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") - extraMap := map[string]string{ - "obj_type": docType, - "file_extension": ext, - } - extraBytes, _ := json.Marshal(extraMap) - - // Build SDK Formdata - fd := larkcore.NewFormdata() - fd.AddField("file_name", fileName) - fd.AddField("parent_type", "ccm_import_open") - fd.AddField("size", fmt.Sprintf("%d", fileSize)) - fd.AddField("extra", string(extraBytes)) - fd.AddFile("file", f) - - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ - HttpMethod: http.MethodPost, - ApiPath: "/open-apis/drive/v1/medias/upload_all", - Body: fd, - }, larkcore.WithFileUpload()) - if err != nil { - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - // Preserve already-classified CLI errors from lower layers instead of - // wrapping them as a generic network failure. - return "", err - } - return "", output.ErrNetwork("upload media failed: %v", err) - } - - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: invalid response JSON: %v", err) - } - - if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { - // Surface the backend error body so callers can see import-specific - // validation failures such as unsupported formats or permission issues. - msg, _ := result["msg"].(string) - return "", output.ErrAPI(larkCode, fmt.Sprintf("upload media failed: [%d] %s", larkCode, msg), result["error"]) - } - - data, _ := result["data"].(map[string]interface{}) - fileToken, _ := data["file_token"].(string) - if fileToken == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: no file_token returned") - } - return fileToken, nil -} diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index 34eb7bf74..370da55b9 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -4,11 +4,20 @@ package drive import ( + "bytes" + "context" + "encoding/json" + "errors" "fmt" + "io" + "net/http" + "os" "path/filepath" "strings" "time" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -19,6 +28,20 @@ var ( driveImportPollInterval = 2 * time.Second ) +const ( + // These limits follow the current product-side import constraints per format. + driveImport20MBFileSizeLimit int64 = 20 * 1024 * 1024 + driveImport100MBFileSizeLimit int64 = 100 * 1024 * 1024 + driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024 + driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024 +) + +type driveMultipartUploadSession struct { + UploadID string + BlockSize int + BlockNum int +} + // driveImportExtToDocTypes defines which source file extensions can be imported // into which Drive-native document types. var driveImportExtToDocTypes = map[string][]string{ @@ -30,7 +53,7 @@ var driveImportExtToDocTypes = map[string][]string{ "markdown": {"docx"}, "html": {"docx"}, "xlsx": {"sheet", "bitable"}, - "xls": {"sheet", "bitable"}, + "xls": {"sheet"}, "csv": {"sheet", "bitable"}, } @@ -70,6 +93,269 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} } } +// uploadMediaForImport uploads the source file to the temporary import media +// endpoint and returns the file token consumed by import_tasks. +func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { + importInfo, err := os.Stat(filePath) + if err != nil { + return "", output.ErrValidation("cannot read file: %s", err) + } + + fileSize := importInfo.Size() + if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil { + return "", err + } + fileSizeValue, err := driveUploadSizeValue(fileSize) + if err != nil { + return "", err + } + + extra, err := buildImportMediaExtra(filePath, docType) + if err != nil { + return "", err + } + + if fileSize <= maxDriveUploadFileSize { + fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize)) + return uploadMediaForImportAll(runtime, filePath, fileName, fileSizeValue, extra) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize)) + return uploadMediaForImportMultipart(runtime, filePath, fileName, fileSizeValue, extra) +} + +func uploadMediaForImportAll(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", output.ErrValidation("cannot read file: %s", err) + } + defer f.Close() + + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", "ccm_import_open") + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + fd.AddField("extra", extra) + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + return "", wrapDriveUploadRequestError(err, "upload media failed") + } + + data, err := parseDriveUploadResponse(apiResp, "upload media failed") + if err != nil { + return "", err + } + return extractDriveUploadFileToken(data, "upload media failed") +} + +func uploadMediaForImportMultipart(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) { + session, err := prepareMediaImportUpload(runtime, fileName, fileSize, extra) + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload prepare failed: %s\n", err) + return "", err + } + + totalBlocks := session.BlockNum + fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", totalBlocks, common.FormatSize(int64(session.BlockSize))) + + f, err := os.Open(filePath) + if err != nil { + return "", output.ErrValidation("cannot read file: %s", err) + } + defer f.Close() + + buffer := make([]byte, session.BlockSize) + remaining := fileSize + uploadedBlocks := 0 + for remaining > 0 { + chunkSize := session.BlockSize + if chunkSize > remaining { + chunkSize = remaining + } + + n, readErr := io.ReadFull(f, buffer[:chunkSize]) + if readErr != nil { + return "", output.ErrValidation("cannot read file: %s", readErr) + } + + if err = uploadMediaImportPart(runtime, session.UploadID, uploadedBlocks, buffer[:n]); err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload part failed: %s\n", err) + return "", err + } + + remaining -= n + uploadedBlocks++ + } + + if session.BlockNum > 0 && session.BlockNum != uploadedBlocks { + return "", output.Errorf(output.ExitAPI, "api_error", "upload prepare mismatch: expected %d blocks, uploaded %d", session.BlockNum, uploadedBlocks) + } + + return finishMediaImportUpload(runtime, session.UploadID, uploadedBlocks) +} + +func prepareMediaImportUpload(runtime *common.RuntimeContext, fileName string, fileSize int, extra string) (driveMultipartUploadSession, error) { + data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, map[string]interface{}{ + "file_name": fileName, + "parent_type": "ccm_import_open", // For media import uploads, parent_type must be ccm_import_open. + "size": fileSize, + "extra": extra, + "parent_node": "", // For media import uploads, parent_node must be an explicit empty string; unlike medias/upload_all, this field cannot be omitted. + }) + if err != nil { + return driveMultipartUploadSession{}, err + } + + session := driveMultipartUploadSession{ + UploadID: common.GetString(data, "upload_id"), + BlockSize: int(common.GetFloat(data, "block_size")), + BlockNum: int(common.GetFloat(data, "block_num")), + } + if session.UploadID == "" { + return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned") + } + if session.BlockSize <= 0 { + return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned") + } + if session.BlockNum <= 0 { + return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned") + } + return session, nil +} + +func uploadMediaImportPart(runtime *common.RuntimeContext, uploadID string, seq int, chunk []byte) error { + fd := larkcore.NewFormdata() + fd.AddField("upload_id", uploadID) + fd.AddField("seq", seq) + fd.AddField("size", len(chunk)) + fd.AddFile("file", bytes.NewReader(chunk)) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_part", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + return wrapDriveUploadRequestError(err, "upload media part failed") + } + + _, err = parseDriveUploadResponse(apiResp, "upload media part failed") + return err +} + +func finishMediaImportUpload(runtime *common.RuntimeContext, uploadID string, blockNum int) (string, error) { + data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{ + "upload_id": uploadID, + "block_num": blockNum, + }) + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload finish failed: %s\n", err) + return "", err + } + return extractDriveUploadFileToken(data, "upload media finish failed") +} + +func buildImportMediaExtra(filePath, docType string) (string, error) { + extraBytes, err := json.Marshal(map[string]string{ + "obj_type": docType, + "file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."), + }) + if err != nil { + return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err) + } + return string(extraBytes), nil +} + +func driveImportFileSizeLimit(filePath, docType string) (int64, bool) { + // Keep the limit mapping local to import flows so we do not widen behavior + // changes beyond drive +import. + switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") { + case "docx", "doc": + return driveImport600MBFileSizeLimit, true + case "txt", "md", "mark", "markdown", "html", "xls": + return driveImport20MBFileSizeLimit, true + case "xlsx": + return driveImport800MBFileSizeLimit, true + case "csv": + if docType == "bitable" { + return driveImport100MBFileSizeLimit, true + } + return driveImport20MBFileSizeLimit, true + default: + return 0, false + } +} + +func validateDriveImportFileSize(filePath, docType string, fileSize int64) error { + limit, ok := driveImportFileSizeLimit(filePath, docType) + if !ok || fileSize <= limit { + return nil + } + + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") + if ext == "csv" { + // CSV is the only source format whose limit depends on the target type. + return output.ErrValidation( + "file %s exceeds %s import limit for .csv when importing as %s", + common.FormatSize(fileSize), + common.FormatSize(limit), + docType, + ) + } + + return output.ErrValidation( + "file %s exceeds %s import limit for .%s", + common.FormatSize(fileSize), + common.FormatSize(limit), + ext, + ) +} + +func driveUploadSizeValue(fileSize int64) (int, error) { + maxInt := int64(^uint(0) >> 1) + if fileSize > maxInt { + return 0, output.ErrValidation("file %s is too large to upload", common.FormatSize(fileSize)) + } + return int(fileSize), nil +} + +func wrapDriveUploadRequestError(err error, action string) error { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return output.ErrNetwork("%s: %v", action, err) +} + +func parseDriveUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) { + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err) + } + + if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { + msg, _ := result["msg"].(string) + return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + return data, nil +} + +func extractDriveUploadFileToken(data map[string]interface{}, action string) (string, error) { + fileToken := common.GetString(data, "file_token") + if fileToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action) + } + return fileToken, nil +} + // validateDriveImportSpec enforces the CLI-level compatibility rules before any // upload or import request is sent to the backend. func validateDriveImportSpec(spec driveImportSpec) error { @@ -101,8 +387,10 @@ func validateDriveImportSpec(spec driveImportSpec) error { if !typeAllowed { var hint string switch ext { - case "xlsx", "xls", "csv": + case "xlsx", "csv": hint = fmt.Sprintf(".%s files can only be imported as 'sheet' or 'bitable', not '%s'", ext, spec.DocType) + case "xls": + hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType) default: hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType) } diff --git a/shortcuts/drive/drive_import_common_test.go b/shortcuts/drive/drive_import_common_test.go index 83ea1c1ca..85b977630 100644 --- a/shortcuts/drive/drive_import_common_test.go +++ b/shortcuts/drive/drive_import_common_test.go @@ -5,12 +5,20 @@ package drive import ( "bytes" + "encoding/json" + "errors" + "io" + "mime" + "mime/multipart" "os" "strings" "testing" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" ) func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) { @@ -25,6 +33,75 @@ func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) { } } +func TestValidateDriveImportSpecRejectsXlsBitable(t *testing.T) { + t.Parallel() + + err := validateDriveImportSpec(driveImportSpec{ + FilePath: "./data.xls", + DocType: "bitable", + }) + if err == nil || !strings.Contains(err.Error(), ".xls files can only be imported as 'sheet'") { + t.Fatalf("expected xls-only-sheet validation error, got %v", err) + } +} + +func TestValidateDriveImportFileSize(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + docType string + fileSize int64 + wantText string + }{ + { + name: "docx exceeds 600mb limit", + filePath: "./report.docx", + docType: "docx", + fileSize: driveImport600MBFileSizeLimit + 1, + wantText: "exceeds 600.0 MB import limit for .docx", + }, + { + name: "csv sheet exceeds 20mb limit", + filePath: "./data.csv", + docType: "sheet", + fileSize: driveImport20MBFileSizeLimit + 1, + wantText: "exceeds 20.0 MB import limit for .csv when importing as sheet", + }, + { + name: "csv bitable exceeds 100mb limit", + filePath: "./data.csv", + docType: "bitable", + fileSize: driveImport100MBFileSizeLimit + 1, + wantText: "exceeds 100.0 MB import limit for .csv when importing as bitable", + }, + { + name: "xlsx within 800mb limit", + filePath: "./data.xlsx", + docType: "sheet", + fileSize: driveImport800MBFileSizeLimit, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateDriveImportFileSize(tt.filePath, tt.docType, tt.fileSize) + if tt.wantText == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantText) { + t.Fatalf("error = %v, want substring %q", err, tt.wantText) + } + }) + } +} + func TestParseDriveImportStatus(t *testing.T) { t.Parallel() @@ -129,3 +206,434 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) { t.Fatalf("stdout missing follow-up command: %s", stdout.String()) } } + +func TestDriveImportUsesMultipartUploadForLargeFile(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + + prepareStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "upload_id": "upload_123", + "block_size": 4 * 1024 * 1024, + "block_num": 6, + }, + }, + } + reg.Register(prepareStub) + + partStubs := make([]*httpmock.Stub, 0, 6) + for i := 0; i < 6; i++ { + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_part", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + partStubs = append(partStubs, stub) + reg.Register(stub) + } + + finishStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_finish", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "file_123", + }, + }, + } + reg.Register(finishStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/import_tasks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"ticket": "tk_import"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/import_tasks/tk_import", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": map[string]interface{}{ + "type": "sheet", + "job_status": 0, + "token": "sheet_123", + "url": "https://example.com/sheets/sheet_123", + }, + }, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1) + + err := mountAndRunDrive(t, DriveImport, []string{ + "+import", + "--file", "large.xlsx", + "--type", "sheet", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte(`"token": "sheet_123"`)) { + t.Fatalf("stdout missing imported token: %s", stdout.String()) + } + + prepareBody := decodeCapturedJSONBody(t, prepareStub) + if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" { + t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open") + } + if got, _ := prepareBody["file_name"].(string); got != "large.xlsx" { + t.Fatalf("prepare file_name = %q, want %q", got, "large.xlsx") + } + if got, _ := prepareBody["size"].(float64); got != float64(maxDriveUploadFileSize+1) { + t.Fatalf("prepare size = %v, want %d", got, maxDriveUploadFileSize+1) + } + + firstPart := decodeCapturedMultipartBody(t, partStubs[0]) + if got := firstPart.Fields["upload_id"]; got != "upload_123" { + t.Fatalf("first part upload_id = %q, want %q", got, "upload_123") + } + if got := firstPart.Fields["seq"]; got != "0" { + t.Fatalf("first part seq = %q, want %q", got, "0") + } + if got := firstPart.Fields["size"]; got != "4194304" { + t.Fatalf("first part size = %q, want %q", got, "4194304") + } + if got := len(firstPart.Files["file"]); got != 4*1024*1024 { + t.Fatalf("first part file size = %d, want %d", got, 4*1024*1024) + } + + lastPart := decodeCapturedMultipartBody(t, partStubs[len(partStubs)-1]) + if got := lastPart.Fields["seq"]; got != "5" { + t.Fatalf("last part seq = %q, want %q", got, "5") + } + if got := lastPart.Fields["size"]; got != "1" { + t.Fatalf("last part size = %q, want %q", got, "1") + } + if got := len(lastPart.Files["file"]); got != 1 { + t.Fatalf("last part file size = %d, want %d", got, 1) + } + + finishBody := decodeCapturedJSONBody(t, finishStub) + if got, _ := finishBody["upload_id"].(string); got != "upload_123" { + t.Fatalf("finish upload_id = %q, want %q", got, "upload_123") + } + if got, _ := finishBody["block_num"].(float64); got != 6 { + t.Fatalf("finish block_num = %v, want %d", got, 6) + } +} + +func TestDriveImportMultipartPrepareValidatesResponseFields(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + wantText string + }{ + { + name: "missing upload id", + data: map[string]interface{}{ + "block_size": 4 * 1024 * 1024, + "block_num": 6, + }, + wantText: "upload prepare failed: no upload_id returned", + }, + { + name: "missing block size", + data: map[string]interface{}{ + "upload_id": "upload_123", + "block_num": 6, + }, + wantText: "upload prepare failed: invalid block_size returned", + }, + { + name: "missing block num", + data: map[string]interface{}{ + "upload_id": "upload_123", + "block_size": 4 * 1024 * 1024, + }, + wantText: "upload prepare failed: invalid block_num returned", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": tt.data, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1) + + err := mountAndRunDrive(t, DriveImport, []string{ + "+import", + "--file", "large.xlsx", + "--type", "sheet", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantText) { + t.Fatalf("error = %v, want substring %q", err, tt.wantText) + } + }) + } +} + +func TestDriveImportMultipartUploadPartAPIFailure(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "upload_id": "upload_123", + "block_size": 4 * 1024 * 1024, + "block_num": 6, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_part", + Body: map[string]interface{}{ + "code": 999, + "msg": "chunk rejected", + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1) + + err := mountAndRunDrive(t, DriveImport, []string{ + "+import", + "--file", "large.xlsx", + "--type", "sheet", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveImportMultipartFinishRequiresFileToken(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "upload_id": "upload_123", + "block_size": 4 * 1024 * 1024, + "block_num": 6, + }, + }, + }) + for i := 0; i < 6; i++ { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_part", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + } + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_finish", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1) + + err := mountAndRunDrive(t, DriveImport, []string{ + "+import", + "--file", "large.xlsx", + "--type", "sheet", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + writeSizedDriveImportFile(t, "too-large.csv", driveImport100MBFileSizeLimit+1) + + err := mountAndRunDrive(t, DriveImport, []string{ + "+import", + "--file", "too-large.csv", + "--type", "bitable", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected size limit error, got nil") + } + if !strings.Contains(err.Error(), "exceeds 100.0 MB import limit for .csv when importing as bitable") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseDriveUploadResponseErrors(t *testing.T) { + t.Parallel() + + t.Run("invalid json", func(t *testing.T) { + t.Parallel() + + _, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed") + if err == nil || !strings.Contains(err.Error(), "invalid response JSON") { + t.Fatalf("expected invalid JSON error, got %v", err) + } + }) + + t.Run("api code error", func(t *testing.T) { + t.Parallel() + + _, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed") + if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") { + t.Fatalf("expected API error, got %v", err) + } + }) +} + +func TestWrapDriveUploadRequestError(t *testing.T) { + t.Parallel() + + t.Run("preserves exit error", func(t *testing.T) { + t.Parallel() + + original := output.ErrValidation("bad input") + got := wrapDriveUploadRequestError(original, "upload media failed") + if got != original { + t.Fatalf("expected same exit error pointer, got %v", got) + } + }) + + t.Run("wraps generic error as network", func(t *testing.T) { + t.Parallel() + + got := wrapDriveUploadRequestError(io.EOF, "upload media failed") + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if exitErr.Code != output.ExitNetwork { + t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork) + } + if !strings.Contains(got.Error(), "upload media failed") { + t.Fatalf("unexpected error: %v", got) + } + }) +} + +type capturedMultipartBody struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} { + t.Helper() + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode captured JSON body: %v", err) + } + return body +} + +func writeSizedDriveImportFile(t *testing.T, name string, size int64) { + t.Helper() + + fh, err := os.Create(name) + if err != nil { + t.Fatalf("Create(%q) error: %v", name, err) + } + if err := fh.Truncate(size); err != nil { + t.Fatalf("Truncate(%q) error: %v", name, err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close(%q) error: %v", name, err) + } +} + +func decodeCapturedMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipartBody { + t.Helper() + + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse multipart content type: %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := capturedMultipartBody{ + Fields: map[string]string{}, + Files: map[string][]byte{}, + } + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("read multipart part: %v", err) + } + + data, err := io.ReadAll(part) + if err != nil { + t.Fatalf("read multipart data: %v", err) + } + if part.FileName() != "" { + body.Files[part.FormName()] = data + continue + } + body.Fields[part.FormName()] = string(data) + } + return body +} diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go index ee4cbea64..91301a1d9 100644 --- a/shortcuts/drive/drive_import_test.go +++ b/shortcuts/drive/drive_import_test.go @@ -6,6 +6,8 @@ package drive import ( "context" "encoding/json" + "os" + "strings" "testing" "github.com/spf13/cobra" @@ -66,7 +68,12 @@ func TestImportTargetFileName(t *testing.T) { } func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) { - t.Parallel() + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.WriteFile("base-import.xlsx", []byte("fake-xlsx"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } cmd := &cobra.Command{Use: "drive +import"} cmd.Flags().String("file", "", "") @@ -83,7 +90,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) { t.Fatalf("set --folder-token: %v", err) } - runtime := common.TestNewRuntimeContext(cmd, nil) + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) dry := DriveImport.DryRun(context.Background(), runtime) if dry == nil { t.Fatal("DryRun returned nil") @@ -117,6 +124,207 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) { } } +func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + fh, err := os.Create("large.xlsx") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(int64(maxDriveUploadFileSize) + 1); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + cmd := &cobra.Command{Use: "drive +import"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("name", "", "") + if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("type", "sheet"); err != nil { + t.Fatalf("set --type: %v", err) + } + + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveImport.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if len(got.API) != 5 { + t.Fatalf("expected 5 API calls, got %d", len(got.API)) + } + if got.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" { + t.Fatalf("dry-run first URL = %q, want upload_prepare", got.API[0].URL) + } + if got.API[1].URL != "/open-apis/drive/v1/medias/upload_part" { + t.Fatalf("dry-run second URL = %q, want upload_part", got.API[1].URL) + } + if got.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" { + t.Fatalf("dry-run third URL = %q, want upload_finish", got.API[2].URL) + } +} + +func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +import"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("name", "", "") + if err := cmd.Flags().Set("file", "../outside.md"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("type", "docx"); err != nil { + t.Fatalf("set --type: %v", err) + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + dry := DriveImport.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct{} `json:"api"` + Error string `json:"error"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if got.Error == "" || !strings.Contains(got.Error, "unsafe file path") { + t.Fatalf("dry-run error = %q, want unsafe file path error", got.Error) + } + if len(got.API) != 0 { + t.Fatalf("expected no API calls when preflight fails, got %d", len(got.API)) + } +} + +func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + fh, err := os.Create("large.md") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(driveImport20MBFileSizeLimit + 5*1024*1024); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + cmd := &cobra.Command{Use: "drive +import"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("name", "", "") + if err := cmd.Flags().Set("file", "./large.md"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("type", "docx"); err != nil { + t.Fatalf("set --type: %v", err) + } + + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveImport.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct{} `json:"api"` + Error string `json:"error"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if got.Error == "" || !strings.Contains(got.Error, "exceeds 20.0 MB import limit for .md") { + t.Fatalf("dry-run error = %q, want oversized markdown error", got.Error) + } + if len(got.API) != 0 { + t.Fatalf("expected no API calls when size preflight fails, got %d", len(got.API)) + } +} + +func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.Mkdir("folder-input", 0755); err != nil { + t.Fatalf("Mkdir() error: %v", err) + } + + cmd := &cobra.Command{Use: "drive +import"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("name", "", "") + if err := cmd.Flags().Set("file", "./folder-input"); err != nil { + t.Fatalf("set --file: %v", err) + } + if err := cmd.Flags().Set("type", "docx"); err != nil { + t.Fatalf("set --type: %v", err) + } + + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveImport.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct{} `json:"api"` + Error string `json:"error"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if got.Error == "" || !strings.Contains(got.Error, "file must be a regular file") { + t.Fatalf("dry-run error = %q, want regular file error", got.Error) + } + if len(got.API) != 0 { + t.Fatalf("expected no API calls when file type preflight fails, got %d", len(got.API)) + } +} + func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) { t.Parallel() diff --git a/skills/lark-drive/references/lark-drive-import.md b/skills/lark-drive/references/lark-drive-import.md index 58041bda5..ab176c60e 100644 --- a/skills/lark-drive/references/lark-drive-import.md +++ b/skills/lark-drive/references/lark-drive-import.md @@ -24,15 +24,17 @@ lark-cli drive +import --file ./README.md --type docx --dry-run | 参数 | 必填 | 说明 | |------|------|------| -| `--file` | 是 | 本地文件路径,根据文件后缀名自动推断 `file_extension`,最大支持 20MB | +| `--file` | 是 | 本地文件路径,根据文件后缀名自动推断 `file_extension`;文件需满足对应格式的导入大小限制,超过 20MB 且仍在允许范围内时会自动切换分片上传 | | `--type` | 是 | 导入目标云文档格式。可选值:`docx` (新版文档)、`sheet` (电子表格)、`bitable` (多维表格) | | `--folder-token` | 否 | 目标文件夹 token,不传则请求中的 `point.mount_key` 为空字符串,Import API 会将其解释为导入到云空间根目录 | | `--name` | 否 | 导入后的在线云文档名称,不传默认使用本地文件名去掉扩展名后的结果 | ## 行为说明 -- **三步执行**:此 shortcut 内部封装了完整流程: - 1. 自动调用素材上传接口 (`/open-apis/drive/v1/medias/upload_all`) 获取源文件的 `file_token` +- **完整执行流程**:此 shortcut 内部封装了完整流程: + 1. 自动上传源文件获取 `file_token`: + - 20MB 及以下:调用素材上传接口 `POST /open-apis/drive/v1/medias/upload_all` + - 超过 20MB:自动切换为分片上传 `upload_prepare -> upload_part -> upload_finish` 2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数 3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令 - **默认根目录行为**:不传 `--folder-token` 时,shortcut 会保留空的 `point.mount_key`,Lark Import API 会将其视为“导入到调用者根目录”。 @@ -47,21 +49,41 @@ lark-cli drive +import --file ./README.md --type docx --dry-run | `.txt` | `docx` | 纯文本文件 | | `.md`, `.markdown`, `.mark` | `docx` | Markdown 文档 | | `.html` | `docx` | HTML 文档 | -| `.xlsx`, `.xls` | `sheet`, `bitable` | Microsoft Excel 表格 | +| `.xlsx` | `sheet`, `bitable` | Microsoft Excel 表格 | +| `.xls` | `sheet` | Microsoft Excel 97-2003 表格 | | `.csv` | `sheet`, `bitable` | CSV 数据文件 | > [!IMPORTANT] > 文件扩展名与目标文档类型必须匹配,否则会返回验证错误: > - 文档类文件(.docx, .doc, .txt, .md, .html)**只能**导入为 `docx` -> - 表格类文件(.xlsx, .xls, .csv)**只能**导入为 `sheet` 或 `bitable` +> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable` +> - `.xls` 文件**只能**导入为 `sheet` > - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet` +### 文件大小限制 + +除扩展名与目标类型匹配外,`drive +import` 还会在本地上传前校验格式级大小限制: + +| 本地文件扩展名 | 导入目标 | 大小上限 | +|--------------|---------|---------| +| `.docx`, `.doc` | `docx` | 600MB | +| `.txt` | `docx` | 20MB | +| `.md`, `.mark`, `.markdown` | `docx` | 20MB | +| `.html` | `docx` | 20MB | +| `.xlsx` | `sheet`, `bitable` | 800MB | +| `.csv` | `sheet` | 20MB | +| `.csv` | `bitable` | 100MB | +| `.xls` | `sheet` | 20MB | + +- 如果文件超出对应上限,shortcut 会在真正上传前直接返回验证错误。 +- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。 + - 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。 - 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上: - `ready=false` - `timed_out=true` - `next_command`:可直接复制执行的后续查询命令,例如 `lark-cli drive +task_result --scenario import --ticket ` -- 如果文件超过 20MB 上限,或者文件扩展名不被支持,执行时将抛出验证错误。 +- 如果文件扩展名不被支持,执行时将抛出验证错误。 ### 超时后的继续查询