diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 24cbd69234..0584af692d 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1960,16 +1960,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "has_more": false, - "record_id_list": []interface{}{"rec_1"}, - "update": map[string]interface{}{"Status": "Done"}, + "ignored_fields": []interface{}{"Formula"}, }, }, }) - if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil { + if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) { + if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) { t.Fatalf("stdout=%s", got) } }) @@ -1981,20 +1979,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update", Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{ - "record_id_list": []interface{}{"rec_1"}, - }, + "data": map[string]interface{}{}, }, } reg.Register(updateStub) - if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil { + input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}` + if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) { - t.Fatalf("stdout=%s", got) - } body := string(updateStub.CapturedBody) - if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) { + if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) { t.Fatalf("request body=%s", body) } }) diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 9810cd7089..048df30d4a 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -768,7 +768,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) { name: "record batch update json", shortcut: BaseRecordBatchUpdate, wantHelp: []string{ - `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, + "update_records maps each record ID to its field map", + `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, }, }, } @@ -835,9 +836,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) { name: "record batch update", shortcut: BaseRecordBatchUpdate, wantTips: []string{ - "Happy path fields: record_id_list is the target record IDs", - "patch is a field map applied unchanged to every target record", - "Do not use +record-batch-update for per-row different values", + "Happy path field: update_records", + "update_records maps each record ID to its own field map", + `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, + "contains only optional ignored_fields", + "does not check whether record IDs exist", "use +field-list to confirm real writable fields", "Batch update supports max 200 records per call", "CellValue happy path: text/phone/url", diff --git a/shortcuts/base/record_batch_update.go b/shortcuts/base/record_batch_update.go index 74a52cea80..0fc23a1797 100644 --- a/shortcuts/base/record_batch_update.go +++ b/shortcuts/base/record_batch_update.go @@ -12,18 +12,19 @@ import ( var BaseRecordBatchUpdate = common.Shortcut{ Service: "base", Command: "+record-batch-update", - Description: "Batch update records", + Description: "Batch update records with record-specific fields", Risk: "write", Scopes: []string{"base:record:update"}, AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true}, + {Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true}, }, Tips: append([]string{ - "Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.", - "Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.", + "Happy path field: update_records maps each record ID to its own field map.", + `Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`, + "The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.", "Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.", "Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.", }, recordCellValueHappyPathTips...), diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 3399146dc9..88edef4bdd 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -110,7 +110,7 @@ metadata: - 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。 - 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。 - 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。 -- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。 +- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值。 - select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。 ## 表单与视图细节 diff --git a/skills/lark-base/references/lark-base-cell-value.md b/skills/lark-base/references/lark-base-cell-value.md index bb9cd6631d..8c299e7095 100644 --- a/skills/lark-base/references/lark-base-cell-value.md +++ b/skills/lark-base/references/lark-base-cell-value.md @@ -9,7 +9,7 @@ - `--json` 必须是 JSON 对象。 - `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`。 - `+record-batch-create`:`rows` 是 `CellValue[][]`,列顺序由 `fields` 决定。 -- `+record-batch-update`:`patch` 是 `Map`,同一份 `patch` 会应用到所有 `record_id_list`。 +- `+record-batch-update`:使用 `update_records`,其每个 value 都是 `Map`。 - 一次 payload 里同一字段只用一种 key(字段名或字段 ID),不要重复。 - 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。 - 需要清空字段时优先传 `null`(字段允许清空时)。 diff --git a/skills/lark-base/references/lark-base-record-batch-update.md b/skills/lark-base/references/lark-base-record-batch-update.md index c3ad870004..29146c5e53 100644 --- a/skills/lark-base/references/lark-base-record-batch-update.md +++ b/skills/lark-base/references/lark-base-record-batch-update.md @@ -2,13 +2,13 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -批量更新记录(将同一份 `patch` 批量应用到一批 `record_id_list`)。 +通过 `update_records` 为每条记录提交字段值。 ## 推荐命令 ```bash lark-cli base +record-batch-update --base-token --table-id \ - --json '{"record_id_list":[""],"patch":{"状态":"完成"}}' + --json '{"update_records":{"":{"状态":["完成"]},"":{"分数":20}}}' lark-cli base +record-batch-update --base-token --table-id --json @batch-update.json ``` @@ -29,23 +29,25 @@ lark-cli base +record-batch-update --base-token --table-id ` | 是 | 字段更新对象;key 是字段名或字段 ID,value 是 `CellValue`;同一份 `patch` 会应用到 `record_id_list` 内所有记录 | +| `update_records` | `Map>` | 是 | record ID 到字段更新对象的映射(单次最多 200 条) | ## 返回重点 -返回 `record_id_list`、`update`,可选返回 `ignored_fields`;`update` 可能为空对象。 +成功响应只包含可选的 `ignored_fields`;没有忽略字段时 `data` 为空对象。请求不会预先校验 record ID 是否存在,因此需要确认实际写入结果时,应再用 `+record-get` 读回目标记录。 ## 坑点 -- 这是“同值批量更新”:所有 `record_id_list` 都应用同一份 `patch`。 -- `record_id_list` 最大 200 条,超过会被接口校验拒绝。 +- 单次最多更新 200 条记录,超过会被接口校验拒绝。 - 命令不会自动做字段/行映射转换,传什么就发什么。 -- 如果 `patch` 包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。 +- 如果字段映射包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。 ## 参考 diff --git a/tests/cli_e2e/base/base_record_batch_update_dryrun_test.go b/tests/cli_e2e/base/base_record_batch_update_dryrun_test.go new file mode 100644 index 0000000000..fdb06381e9 --- /dev/null +++ b/tests/cli_e2e/base/base_record_batch_update_dryrun_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestBaseRecordBatchUpdatePerRecordDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-batch-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--json", `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update", clie2e.DryRunGet(out, "api.0.url").String(), out) + require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out) + require.Equal(t, "Done", clie2e.DryRunGet(out, "api.0.body.update_records.recA.Status.0").String(), out) + require.Equal(t, int64(20), clie2e.DryRunGet(out, "api.0.body.update_records.recB.Score").Int(), out) +} diff --git a/tests/cli_e2e/base/base_record_batch_update_workflow_test.go b/tests/cli_e2e/base/base_record_batch_update_workflow_test.go new file mode 100644 index 0000000000..9b375925ff --- /dev/null +++ b/tests/cli_e2e/base/base_record_batch_update_workflow_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-batch-update-"+clie2e.GenerateSuffix()) + tableID, _, _ := createTableWithRetry( + t, + parentT, + ctx, + baseToken, + "Batch Update "+clie2e.GenerateSuffix(), + `[{"name":"Name","type":"text"},{"name":"Status","type":"select","multiple":false,"options":[{"name":"Open"},{"name":"Done"}]},{"name":"Score","type":"number"}]`, + `{"name":"Main","type":"grid"}`, + ) + + createResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-batch-create", + "--base-token", baseToken, + "--table-id", tableID, + "--json", `{"fields":["Name","Status","Score"],"rows":[["alpha","Open",10],["beta","Open",15]]}`, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + createResult.AssertExitCode(t, 0) + createResult.AssertStdoutStatus(t, true) + + firstRecordID := gjson.Get(createResult.Stdout, "data.record_id_list.0").String() + secondRecordID := gjson.Get(createResult.Stdout, "data.record_id_list.1").String() + require.NotEmpty(t, firstRecordID, "stdout:\n%s", createResult.Stdout) + require.NotEmpty(t, secondRecordID, "stdout:\n%s", createResult.Stdout) + + updateBody, err := json.Marshal(map[string]map[string]map[string]any{ + "update_records": { + firstRecordID: {"Status": []string{"Done"}}, + secondRecordID: {"Score": 20}, + }, + }) + require.NoError(t, err) + + updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-batch-update", + "--base-token", baseToken, + "--table-id", tableID, + "--json", string(updateBody), + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + updateResult.AssertExitCode(t, 0) + updateResult.AssertStdoutStatus(t, true) + + assertRecordFields := func(recordID, expectedStatus string, expectedScore int64) { + t.Helper() + result, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-get", + "--base-token", baseToken, + "--table-id", tableID, + "--record-id", recordID, + "--field-id", "Status", + "--field-id", "Score", + "--format", "json", + }, + DefaultAs: "bot", + }) + require.NoError(t, runErr) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, recordID, gjson.Get(result.Stdout, "data.record_id_list.0").String(), result.Stdout) + require.Equal(t, "Status", gjson.Get(result.Stdout, "data.fields.0").String(), result.Stdout) + require.Equal(t, "Score", gjson.Get(result.Stdout, "data.fields.1").String(), result.Stdout) + require.Equal(t, expectedStatus, gjson.Get(result.Stdout, "data.data.0.0.0").String(), result.Stdout) + require.Equal(t, expectedScore, gjson.Get(result.Stdout, "data.data.0.1").Int(), result.Stdout) + } + + assertRecordFields(firstRecordID, "Done", 10) + assertRecordFields(secondRecordID, "Open", 20) + + missingResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-batch-update", + "--base-token", baseToken, + "--table-id", tableID, + "--json", `{"update_records":{"recZZZZZZZZZZZ":{"Name":"missing-record"}}}`, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + missingResult.AssertExitCode(t, 0) + missingResult.AssertStdoutStatus(t, true) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index 89472ba746..1a756d47fc 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -2,16 +2,18 @@ ## Metrics - Denominator: 78 leaf commands -- Covered: 19 -- Coverage: 24.4% +- Covered: 22 +- Coverage: 28.2% ## Summary - TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`. - TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data. - TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path. +- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape. +- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base. - TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`. - Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered. -- Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite. +- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite. ## Command Table @@ -54,10 +56,10 @@ | ✕ | base +form-questions-list | shortcut | | none | form workflows not covered | | ✕ | base +form-questions-update | shortcut | | none | form workflows not covered | | ✕ | base +form-update | shortcut | | none | form workflows not covered | -| ✕ | base +record-batch-create | shortcut | | none | record workflows not covered | -| ✕ | base +record-batch-update | shortcut | | none | record workflows not covered | +| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.fields`; `--json.rows` | seeds heterogeneous live workflow records | +| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification | | ✕ | base +record-delete | shortcut | | none | record workflows not covered | -| ✕ | base +record-get | shortcut | | none | record workflows not covered | +| ✓ | base +record-get | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--record-id`; repeated `--field-id`; `--format json` | reads back select and number values after batch update | | ✕ | base +record-history-list | shortcut | | none | record workflows not covered | | ✕ | base +record-list | shortcut | | none | record workflows not covered | | ✕ | base +record-search | shortcut | | none | record workflows not covered |