From 1ba4f3973cf09dd98c2355e0f082ecf95bea005c Mon Sep 17 00:00:00 2001 From: wangweiming-01 Date: Mon, 6 Jul 2026 10:46:46 +0800 Subject: [PATCH] fix: guide drive import concurrency conflicts (#1751) --- shortcuts/drive/drive_import_common.go | 48 ++++++++++-- shortcuts/drive/drive_import_common_test.go | 77 +++++++++++++++++++ skill-template/domains/drive.md | 2 + skills/lark-drive/SKILL.md | 2 + .../references/lark-drive-import.md | 8 ++ 5 files changed, 132 insertions(+), 5 deletions(-) diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index daea5e050..71917a17a 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "strconv" "strings" "time" @@ -28,6 +29,8 @@ const ( driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024 driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024 driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024 + + driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict." ) // driveImportExtToDocTypes defines which source file extensions can be imported @@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{ "pptx": {"slides"}, } +var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001} + // driveImportSpec contains the user-facing import inputs after normalization. type driveImportSpec struct { FilePath string @@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm return status, true, nil } if status.Failed() { - msg := strings.TrimSpace(status.JobErrorMsg) - if msg == "" { - msg = status.StatusLabel() - } - return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg) + return status, false, driveImportFailureError(status) } } if !hadSuccessfulPoll && lastErr != nil { @@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm return lastStatus, false, nil } + +func driveImportFailureError(status driveImportStatus) *errs.APIError { + msg := strings.TrimSpace(status.JobErrorMsg) + if msg == "" { + msg = status.StatusLabel() + } + + apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg) + if code, ok := driveImportConcurrentOperationCode(msg); ok { + apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint) + } + return apiErr +} + +func driveImportConcurrentOperationCode(msg string) (int, bool) { + for _, code := range driveImportConcurrentOperationCodes { + codeText := strconv.Itoa(code) + for idx := strings.Index(msg, codeText); idx >= 0; { + end := idx + len(codeText) + if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) { + return code, true + } + + nextStart := idx + 1 + next := strings.Index(msg[nextStart:], codeText) + if next < 0 { + break + } + idx = nextStart + next + } + } + return 0, false +} + +func isASCIIDigit(ch byte) bool { + return ch >= '0' && ch <= '9' +} diff --git a/shortcuts/drive/drive_import_common_test.go b/shortcuts/drive/drive_import_common_test.go index a59e31feb..c44ab9b32 100644 --- a/shortcuts/drive/drive_import_common_test.go +++ b/shortcuts/drive/drive_import_common_test.go @@ -7,6 +7,7 @@ import ( "bytes" "errors" "os" + "strconv" "strings" "testing" @@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) { } } +func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) { + t.Parallel() + + for _, code := range driveImportConcurrentOperationCodes { + t.Run(strconv.Itoa(code), func(t *testing.T) { + t.Parallel() + + err := driveImportFailureError(driveImportStatus{ + JobStatus: 3, + JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:", + }) + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T", err) + } + if problem.Category != errs.CategoryAPI { + t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI) + } + if problem.Subtype != errs.SubtypeServerError { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError) + } + if problem.Code != code { + t.Fatalf("code = %d, want %d", problem.Code, code) + } + if !problem.Retryable { + t.Fatal("expected retryable error") + } + if problem.Hint != driveImportConcurrentOperationHint { + t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint) + } + }) + } +} + +func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msg string + }{ + { + name: "ordinary failure", + msg: "unsupported conversion", + }, + { + name: "longer numeric code containing known code", + msg: "call CreateObjNode return error code, code: 12321401012, message:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := driveImportFailureError(driveImportStatus{ + JobStatus: 3, + JobErrorMsg: tt.msg, + }) + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T", err) + } + if problem.Code != 0 { + t.Fatalf("code = %d, want 0", problem.Code) + } + if problem.Retryable { + t.Fatal("expected non-concurrency failure to remain non-retryable") + } + if problem.Hint != "" { + t.Fatalf("hint = %q, want empty", problem.Hint) + } + }) + } +} + func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ diff --git a/skill-template/domains/drive.md b/skill-template/domains/drive.md index 99fbabce2..1c56da819 100644 --- a/skill-template/domains/drive.md +++ b/skill-template/domains/drive.md @@ -6,6 +6,7 @@ - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。 +- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。 - 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。 - `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。 @@ -194,6 +195,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": " | `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | | `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) | +| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 | ### 授权当前应用访问文档 diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index a1f1b9089..0c5e90ce1 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -29,6 +29,7 @@ metadata: - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 - 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。 +- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。 - 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。 - 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。 - 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。 @@ -102,6 +103,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' | `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | | `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) | +| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 | ### 权限能力入口 diff --git a/skills/lark-drive/references/lark-drive-import.md b/skills/lark-drive/references/lark-drive-import.md index aaf0d7d6e..544f164ad 100644 --- a/skills/lark-drive/references/lark-drive-import.md +++ b/skills/lark-drive/references/lark-drive-import.md @@ -14,6 +14,13 @@ > [!IMPORTANT] > 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。 +## 批量导入串行规则 + +> [!IMPORTANT] +> 批量执行 `drive +import` 且目标是同一个位置时,必须串行执行,不要并发发起导入任务。这里的“相同位置”包括同一个 `--folder-token`、都省略 `--folder-token` 导入到默认根目录,或使用同一个 `--target-token` 导入到已有 bitable。 +> +> 如果在同一位置下并发导入,服务端可能返回并发冲突错误。看到错误信息或 `job_error_msg` 中包含 `232140101`、`232140100`、`233523001` 任一错误码时,按同位置并发操作处理:停止并发导入,改为串行处理失败项;每个失败项每次重试前等待几秒,总共最多重试 3 次;仍失败就停止并向用户报告冲突。 + ## 命令 ```bash @@ -143,6 +150,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run - “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。 - 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。 +- 若导入失败信息包含 `232140101`、`232140100`、`233523001`,通常表示同一位置下存在并发导入 / 创建操作;批量场景请改为串行执行,每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突。 - 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上: - `ready=false` - `timed_out=true`