diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index f25b99ac..49c23011 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -71,6 +71,29 @@ func TestDryRunRecordOps(t *testing.T) { ) assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age") + filteredListRT := newBaseTestRuntimeWithArrays( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`, + "sort-json": `[{"field":"Due","desc":true}]`, + }, + nil, + nil, + map[string]int{"limit": 20}, + ) + assertDryRunContains( + t, + dryRunRecordList(ctx, filteredListRT), + "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", + "limit=20", + "filter=%7B", + "Status", + "Todo", + "sort=%5B", + "Due", + ) + commaFieldRT := newBaseTestRuntimeWithArrays( map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, map[string][]string{"field-id": {"A,B", "C"}}, @@ -99,6 +122,33 @@ func TestDryRunRecordOps(t *testing.T) { `"limit":500`, ) + searchFlagRT := newBaseTestRuntimeWithArrays( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "keyword": "Alice", + "view-id": "viw_1", + "filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`, + "sort-json": `[{"field":"Updated At","desc":true}]`, + }, + map[string][]string{ + "search-field": {"Name"}, + "field-id": {"Name", "Status"}, + }, + nil, + map[string]int{"limit": 20}, + ) + assertDryRunContains( + t, + dryRunRecordSearch(ctx, searchFlagRT), + "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search", + `"keyword":"Alice"`, + `"search_fields":["Name"]`, + `"select_fields":["Name","Status"]`, + `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`, + `"sort":[{"desc":true,"field":"Updated At"}]`, + ) + upsertCreateRT := newBaseTestRuntime( map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`}, nil, nil, diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 3ec741ae..e1ed7ee2 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -974,7 +974,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { "+record-search", "--base-token", "app_x", "--table-id", "tbl_x", - "--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`, + "--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`, "--format", "json", }, factory, @@ -990,12 +990,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { !strings.Contains(body, `"keyword":"Created"`) || !strings.Contains(body, `"search_fields":["Title","fld_owner"]`) || !strings.Contains(body, `"select_fields":["Title","fld_owner"]`) || + !strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) || + !strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) || !strings.Contains(body, `"offset":0`) || !strings.Contains(body, `"limit":2`) { t.Fatalf("captured body=%s", body) } }) + t.Run("search with flag filter sort and projection", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + searchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Title", "Status"}, + "field_id_list": []interface{}{"fld_title", "fld_status"}, + "record_id_list": []interface{}{"rec_1"}, + "data": []interface{}{[]interface{}{"Created by AI", "Todo"}}, + "has_more": false, + }, + }, + } + reg.Register(searchStub) + if err := runShortcut( + t, + BaseRecordSearch, + []string{ + "+record-search", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--keyword", "Created", + "--search-field", "Title", + "--field-id", "Title", + "--field-id", "Status", + "--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`, + "--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`, + "--limit", "20", + "--format", "json", + }, + factory, + stdout, + ); err != nil { + t.Fatalf("err=%v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil { + t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody)) + } + if body["keyword"] != "Created" || body["limit"].(float64) != 20 { + t.Fatalf("captured body=%#v", body) + } + filter := body["filter"].(map[string]interface{}) + if filter["logic"] != "and" { + t.Fatalf("filter=%#v", filter) + } + conditions := filter["conditions"].([]interface{}) + if len(conditions) != 2 { + t.Fatalf("conditions=%#v", conditions) + } + sortConfig := body["sort"].([]interface{}) + if len(sortConfig) != 2 { + t.Fatalf("sort=%#v", sortConfig) + } + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Updated At" || firstSort["desc"] != true { + t.Fatalf("sort=%#v", sortConfig) + } + }) + + t.Run("search with filter json file", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + tmp := t.TempDir() + withBaseWorkingDir(t, tmp) + if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil { + t.Fatalf("write filter err=%v", err) + } + searchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Title"}, + "record_id_list": []interface{}{"rec_1"}, + "data": []interface{}{[]interface{}{"A"}}, + "has_more": false, + }, + }, + } + reg.Register(searchStub) + if err := runShortcut( + t, + BaseRecordSearch, + []string{ + "+record-search", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--keyword", "A", + "--search-field", "Title", + "--filter-json", "@filter.json", + "--format", "json", + }, + factory, + stdout, + ); err != nil { + t.Fatalf("err=%v", err) + } + body := string(searchStub.CapturedBody) + if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) { + t.Fatalf("captured body=%s", body) + } + }) + t.Run("search markdown format", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index d6f45573..24d01a21 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -254,35 +254,39 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) { wantHelp: []string{ "field ID or name to include; repeat to project only needed fields", "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view", + `filter JSON object or @file`, + `sort JSON array or @file`, "pagination size, range 1-200", "output format: markdown (default) | json", }, wantTips: []string{ "lark-cli base +record-list --base-token --table-id --limit 50", "lark-cli base +record-list --base-token --table-id --field-id Name --field-id Status --limit 50", + "Text equality filter", + "Option intersection filter", + "Query priority", "Default output is markdown", "Use --field-id repeatedly to keep output small", - "Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view", - "lark-base record read SOP", }, }, { name: "record search", shortcut: BaseRecordSearch, wantHelp: []string{ - `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`, - "for keyword search only", + `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`, + "keyword for record search", + "field ID or name to search", + `filter JSON object or @file`, + `sort JSON array or @file`, "output format: markdown (default) | json", }, wantTips: []string{ - "Happy path fields: keyword (string), search_fields", - "search_fields length 1-20", - "limit range 1-200 defaults to 10", - "view_id scopes search to records in that view", + "Example: lark-cli base +record-search", + "Example with filter/sort JSON", + "Text equality filter", + "Query priority", + "Use --json only when you need to pass the full search body directly", "Default output is markdown", - "only for keyword search", - "lark-base record read SOP", - "inventing search JSON", }, }, { @@ -607,7 +611,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) { name: "record search json", shortcut: BaseRecordSearch, wantHelp: []string{ - `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`, + `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`, }, }, { @@ -885,11 +889,11 @@ func TestBaseTableValidate(t *testing.T) { func TestBaseRecordValidate(t *testing.T) { ctx := context.Background() - if BaseRecordList.Validate != nil { - t.Fatalf("record list validate should be nil for repeatable --field-id") + if BaseRecordList.Validate == nil { + t.Fatalf("record list validate should reject invalid query flags before dry-run") } if BaseRecordSearch.Validate == nil { - t.Fatalf("record search validate should reject invalid JSON before dry-run") + t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run") } if BaseRecordGet.Validate == nil { t.Fatalf("record get validate should reject invalid record selection before dry-run") @@ -900,6 +904,58 @@ func TestBaseRecordValidate(t *testing.T) { if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil { t.Fatalf("record upsert map validate err=%v", err) } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`}, + nil, + nil, + )); err != nil { + t.Fatalf("record list filter-json validate err=%v", err) + } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`}, + nil, + nil, + )); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") { + t.Fatalf("err=%v", err) + } + if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`}, + nil, + nil, + nil, + )); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") { + t.Fatalf("err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") { + t.Fatalf("err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"}, + map[string][]string{"search-field": {"Name"}}, + nil, + nil, + )); err != nil { + t.Fatalf("record search flag validate err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime( + map[string]string{ + "base-token": "b", + "table-id": "tbl_1", + "json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`, + "sort-json": `[{"field":"Title","desc":false}]`, + }, + nil, + nil, + )); err != nil { + t.Fatalf("record search json with sort-json validate err=%v", err) + } + if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime( + map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"}, + nil, + nil, + )); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") { + t.Fatalf("err=%v", err) + } } func TestBaseViewValidate(t *testing.T) { diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go index dfa7256b..b6489a5c 100644 --- a/shortcuts/base/record_list.go +++ b/shortcuts/base/record_list.go @@ -22,6 +22,8 @@ var BaseRecordList = common.Shortcut{ tableRefFlag(true), recordListFieldRefFlag(), recordListViewRefFlag(), + recordFilterFlag(), + recordSortFlag(), {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, {Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), @@ -29,10 +31,21 @@ var BaseRecordList = common.Shortcut{ Tips: []string{ "Example: lark-cli base +record-list --base-token --table-id --limit 50", "Example with projection: lark-cli base +record-list --base-token --table-id --field-id Name --field-id Status --limit 50", + `Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`, + `Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`, + `Number equality filter: --filter-json '{"logic":"and","conditions":[["Score","==",95]]}'`, + `Date equality filter: --filter-json '{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]}'`, + `Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`, + `Sort priority follows --sort-json array order: --sort-json '[{"field":"Updated","desc":true},{"field":"Title","desc":false}]'`, + formatRecordQueryPriorityTip(), "Default output is markdown; pass --format json to get the raw JSON envelope.", "Use --field-id repeatedly to keep output small and aligned with the task.", - "Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.", - "For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateRecordReadFormat(runtime); err != nil { + return err + } + return validateRecordQueryOptions(runtime) }, DryRun: dryRunRecordList, PostMount: func(cmd *cobra.Command) { diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 71cf01f0..af48e6e7 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -217,6 +217,9 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common if viewID := runtime.Str("view-id"); viewID != "" { params.Set("view_id", viewID) } + if err := applyRecordQueryToURLValues(runtime, params); err != nil { + return common.NewDryRunAPI() + } path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode() return common.NewDryRunAPI(). GET(path). @@ -237,8 +240,12 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common. } func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - pc := newParseCtx(runtime) - body, _ := parseJSONObject(pc, runtime.Str("json"), "json") + var body map[string]interface{} + if strings.TrimSpace(runtime.Str("json")) != "" { + body, _ = recordSearchJSONBody(runtime) + } else { + body, _ = recordSearchFlagBody(runtime) + } return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search"). Body(body). @@ -388,6 +395,9 @@ func executeRecordList(runtime *common.RuntimeContext) error { if viewID := runtime.Str("view-id"); viewID != "" { params["view_id"] = viewID } + if err := applyRecordQueryToParams(runtime, params); err != nil { + return err + } data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil) if err != nil { return err @@ -420,8 +430,13 @@ func executeRecordGet(runtime *common.RuntimeContext) error { } func executeRecordSearch(runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + var body map[string]interface{} + var err error + if strings.TrimSpace(runtime.Str("json")) != "" { + body, err = recordSearchJSONBody(runtime) + } else { + body, err = recordSearchFlagBody(runtime) + } if err != nil { return err } diff --git a/shortcuts/base/record_query.go b/shortcuts/base/record_query.go new file mode 100644 index 00000000..0bf37403 --- /dev/null +++ b/shortcuts/base/record_query.go @@ -0,0 +1,248 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + recordFilterJSONFlag = "filter-json" + recordSortJSONFlag = "sort-json" + recordSortMaxCount = 10 +) + +func recordFilterFlag() common.Flag { + return common.Flag{ + Name: recordFilterJSONFlag, + Desc: `filter JSON object or @file, same shape as view filter JSON; overrides --view-id view filters`, + Input: []string{common.File}, + } +} + +func recordSortFlag() common.Flag { + return common.Flag{ + Name: recordSortJSONFlag, + Desc: `sort JSON array or @file, e.g. [{"field":"Updated","desc":true}]; also accepts {"sort_config":[...]}; order is priority; max 10`, + Input: []string{common.File}, + } +} + +func validateRecordQueryOptions(runtime *common.RuntimeContext) error { + if _, err := parseRecordFilterFlag(runtime); err != nil { + return err + } + _, err := parseRecordSortFlag(runtime) + return err +} + +func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) { + filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag)) + if filterRaw == "" { + return nil, nil + } + pc := newParseCtx(runtime) + return parseJSONObject(pc, filterRaw, recordFilterJSONFlag) +} + +func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) { + sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag)) + if sortRaw == "" { + return nil, nil + } + pc := newParseCtx(runtime) + value, err := parseJSONValue(pc, sortRaw, recordSortJSONFlag) + if err != nil { + return nil, err + } + return normalizeRecordSortValue(value, "--"+recordSortJSONFlag) +} + +func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) { + var sortConfig []interface{} + if parsed, ok := value.([]interface{}); ok { + sortConfig = parsed + } else if obj, ok := value.(map[string]interface{}); ok { + rawSortConfig, ok := obj["sort_config"] + if !ok { + return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label) + } + parsed, ok := rawSortConfig.([]interface{}) + if !ok { + return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label) + } + sortConfig = parsed + } else { + return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label) + } + if len(sortConfig) > recordSortMaxCount { + return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig)) + } + return sortConfig, nil +} + +func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) { + data, err := json.Marshal(value) + if err != nil { + return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err) + } + return string(data), nil +} + +func applyRecordQueryToParams(runtime *common.RuntimeContext, params map[string]interface{}) error { + filter, err := parseRecordFilterFlag(runtime) + if err != nil { + return err + } + if filter != nil { + filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter) + if err != nil { + return err + } + params["filter"] = filterJSON + } + sortConfig, err := parseRecordSortFlag(runtime) + if err != nil { + return err + } + if len(sortConfig) > 0 { + sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig) + if err != nil { + return err + } + params["sort"] = sortJSON + } + return nil +} + +func applyRecordQueryToURLValues(runtime *common.RuntimeContext, params url.Values) error { + filter, err := parseRecordFilterFlag(runtime) + if err != nil { + return err + } + if filter != nil { + filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter) + if err != nil { + return err + } + params["filter"] = []string{filterJSON} + } + sortConfig, err := parseRecordSortFlag(runtime) + if err != nil { + return err + } + if len(sortConfig) > 0 { + sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig) + if err != nil { + return err + } + params["sort"] = []string{sortJSON} + } + return nil +} + +func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]interface{}) error { + filter, err := parseRecordFilterFlag(runtime) + if err != nil { + return err + } + if filter != nil { + body["filter"] = filter + } + sortConfig, err := parseRecordSortFlag(runtime) + if err != nil { + return err + } + if len(sortConfig) > 0 { + body["sort"] = sortConfig + } + return nil +} + +func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body := map[string]interface{}{} + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + body["keyword"] = keyword + } + searchFields := runtime.StrArray("search-field") + if len(searchFields) > 0 { + body["search_fields"] = searchFields + } + selectFields := recordListFields(runtime) + if len(selectFields) > 0 { + body["select_fields"] = selectFields + } + if viewID := runtime.Str("view-id"); viewID != "" { + body["view_id"] = viewID + } + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + body["offset"] = offset + body["limit"] = common.ParseIntBounded(runtime, "limit", 1, 200) + return body, applyRecordQueryToBody(runtime, body) +} + +func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + pc := newParseCtx(runtime) + body, err := parseJSONObject(pc, runtime.Str("json"), "json") + if err != nil { + return nil, err + } + if err := normalizeRecordSearchJSONBody(body); err != nil { + return nil, err + } + return body, applyRecordQueryToBody(runtime, body) +} + +func normalizeRecordSearchJSONBody(body map[string]interface{}) error { + if rawSort, ok := body["sort"]; ok { + if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil { + body["sort"] = sortConfig + } else { + return err + } + } + return nil +} + +func validateRecordSearchFlags(runtime *common.RuntimeContext) error { + if err := validateRecordReadFormat(runtime); err != nil { + return err + } + jsonRaw := strings.TrimSpace(runtime.Str("json")) + if jsonRaw != "" { + if recordSearchHasJSONExclusiveFlagInputs(runtime) { + return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json") + } + _, err := recordSearchJSONBody(runtime) + return err + } + if strings.TrimSpace(runtime.Str("keyword")) == "" { + return common.FlagErrorf("--keyword is required unless --json is used") + } + if len(runtime.StrArray("search-field")) == 0 { + return common.FlagErrorf("--search-field is required unless --json is used") + } + return validateRecordQueryOptions(runtime) +} + +func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool { + return strings.TrimSpace(runtime.Str("keyword")) != "" || + len(runtime.StrArray("search-field")) > 0 || + len(recordListFields(runtime)) > 0 || + runtime.Str("view-id") != "" || + runtime.Changed("offset") || + runtime.Changed("limit") +} + +func formatRecordQueryPriorityTip() string { + return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag) +} diff --git a/shortcuts/base/record_query_test.go b/shortcuts/base/record_query_test.go new file mode 100644 index 00000000..1238fa4c --- /dev/null +++ b/shortcuts/base/record_query_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "net/url" + "strings" + "testing" +) + +func TestNormalizeRecordSortValue(t *testing.T) { + t.Run("array", func(t *testing.T) { + sortConfig, err := normalizeRecordSortValue([]interface{}{ + map[string]interface{}{"field": "Updated", "desc": true}, + }, "--sort-json") + if err != nil { + t.Fatalf("err=%v", err) + } + if len(sortConfig) != 1 { + t.Fatalf("sortConfig=%#v", sortConfig) + } + }) + + t.Run("wrapped sort_config", func(t *testing.T) { + sortConfig, err := normalizeRecordSortValue(map[string]interface{}{ + "sort_config": []interface{}{ + map[string]interface{}{"field": "Updated", "desc": false}, + }, + }, "--json.sort") + if err != nil { + t.Fatalf("err=%v", err) + } + first := sortConfig[0].(map[string]interface{}) + if first["field"] != "Updated" || first["desc"] != false { + t.Fatalf("sortConfig=%#v", sortConfig) + } + }) + + t.Run("invalid wrapper", func(t *testing.T) { + _, err := normalizeRecordSortValue(map[string]interface{}{"sort": []interface{}{}}, "--sort-json") + if err == nil || !strings.Contains(err.Error(), "sort_config array") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid sort_config type", func(t *testing.T) { + _, err := normalizeRecordSortValue(map[string]interface{}{"sort_config": "Updated"}, "--sort-json") + if err == nil || !strings.Contains(err.Error(), "--sort-json.sort_config must be a JSON array") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid scalar", func(t *testing.T) { + _, err := normalizeRecordSortValue("Updated", "--sort-json") + if err == nil || !strings.Contains(err.Error(), "must be a JSON array") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestApplyRecordQueryToParams(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`, + "sort-json": `{"sort_config":[{"field":"Updated","desc":true}]}`, + }, + nil, + nil, + ) + params := map[string]interface{}{"view_id": "viw_1"} + if err := applyRecordQueryToParams(runtime, params); err != nil { + t.Fatalf("err=%v", err) + } + if params["view_id"] != "viw_1" { + t.Fatalf("params=%#v", params) + } + var filter map[string]interface{} + if err := json.Unmarshal([]byte(params["filter"].(string)), &filter); err != nil { + t.Fatalf("filter err=%v", err) + } + if filter["logic"] != "and" { + t.Fatalf("filter=%#v", filter) + } + var sortConfig []interface{} + if err := json.Unmarshal([]byte(params["sort"].(string)), &sortConfig); err != nil { + t.Fatalf("sort err=%v", err) + } + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Updated" || firstSort["desc"] != true { + t.Fatalf("sort=%#v", sortConfig) + } +} + +func TestApplyRecordQueryToURLValues(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "filter-json": `{"logic":"or","conditions":[["Score",">",90]]}`, + "sort-json": `[{"field":"Score","desc":false}]`, + }, + nil, + nil, + ) + params := url.Values{"view_id": {"viw_1"}} + if err := applyRecordQueryToURLValues(runtime, params); err != nil { + t.Fatalf("err=%v", err) + } + if got := params.Get("view_id"); got != "viw_1" { + t.Fatalf("view_id=%q", got) + } + if !strings.Contains(params.Get("filter"), `"logic":"or"`) || !strings.Contains(params.Get("sort"), `"field":"Score"`) { + t.Fatalf("params=%#v", params) + } +} + +func TestRecordSearchJSONBodyAppliesQueryFlagOverrides(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "json": `{"keyword":"urgent","search_fields":["Title"],"filter":{"logic":"and","conditions":[["Status","==","Done"]]},"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`, + "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`, + "sort-json": `[{"field":"Score","desc":true}]`, + }, + nil, + nil, + ) + body, err := recordSearchJSONBody(runtime) + if err != nil { + t.Fatalf("err=%v", err) + } + filter := body["filter"].(map[string]interface{}) + conditions := filter["conditions"].([]interface{}) + statusCondition := conditions[0].([]interface{}) + if statusCondition[2] != "Todo" { + t.Fatalf("filter=%#v", filter) + } + sortConfig := body["sort"].([]interface{}) + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Score" || firstSort["desc"] != true { + t.Fatalf("sort=%#v", sortConfig) + } +} + +func TestRecordSearchJSONBodyNormalizesWrappedSort(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{ + "json": `{"keyword":"urgent","search_fields":["Title"],"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`, + }, + nil, + nil, + ) + body, err := recordSearchJSONBody(runtime) + if err != nil { + t.Fatalf("err=%v", err) + } + sortConfig := body["sort"].([]interface{}) + firstSort := sortConfig[0].(map[string]interface{}) + if firstSort["field"] != "Updated" || firstSort["desc"] != false { + t.Fatalf("sort=%#v", sortConfig) + } +} diff --git a/shortcuts/base/record_search.go b/shortcuts/base/record_search.go index ff85818a..586e5071 100644 --- a/shortcuts/base/record_search.go +++ b/shortcuts/base/record_search.go @@ -20,21 +20,34 @@ var BaseRecordSearch = common.Shortcut{ Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}; for keyword search only`, Required: true}, + {Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`}, + {Name: "keyword", Desc: "keyword for record search; required unless --json is used"}, + {Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"}, + recordListFieldRefFlag(), + recordListViewRefFlag(), + recordFilterFlag(), + recordSortFlag(), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"}, recordReadFormatFlag(), }, Tips: []string{ `Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`, "JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.", "view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.", + `Example: lark-cli base +record-search --base-token --table-id --keyword Alice --search-field Name --field-id Name --field-id Status --limit 20`, + `Example with filter/sort JSON: lark-cli base +record-search --base-token --table-id --keyword Alice --search-field Name --filter-json @filter.json --sort-json '[{"field":"Updated","desc":true}]'`, + `Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`, + `Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`, + `Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`, + `Sort priority follows --sort-json array order.`, + formatRecordQueryPriorityTip(), + "Use +record-search for keyword matching; use --filter-json for structured conditions and --sort-json for result ordering.", + "Use --json only when you need to pass the full search body directly.", "Default output is markdown; pass --format json to get the raw JSON envelope.", - "Use +record-search only for keyword search; for structured conditions, sorting, Top/Bottom N, or global conclusions, follow the lark-base record read SOP instead of inventing search JSON.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if err := validateRecordReadFormat(runtime); err != nil { - return err - } - return validateRecordJSON(runtime) + return validateRecordSearchFlags(runtime) }, DryRun: dryRunRecordSearch, PostMount: func(cmd *cobra.Command) { diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index b3efa34d..3e78994b 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -43,28 +43,28 @@ metadata: | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | | 管理表 | `+table-list/get/create/update/delete` | `+table-create --fields` 复杂时读 `lark-base-field-json.md` | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | -| 创建/更新字段 | `+field-create` / `+field-update` | 必读 `lark-base-field-json.md`;公式读 `formula-field-guide.md`;lookup 读 `lookup-field-guide.md`;命令细节读 `lark-base-field-create.md` / `lark-base-field-update.md` | -| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 `lark-base-data-analysis-sop.md` | -| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读对应 record reference 和 `lark-base-cell-value.md` | +| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | +| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | +| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | | 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 | -| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 `lark-base-record-history-list.md`,只查单条记录,不做整表审计 | -| 管理视图 | `+view-*` | `+view-set-filter` 读 `lark-base-view-set-filter.md`;其余配置先 get 现状,再按返回结构更新 | -| 一次性聚合统计 | `+data-query` | 必读 `lark-base-data-analysis-sop.md` 和入口 `lark-base-data-query-guide.md`;完整 DSL 再读 `lark-base-data-query.md` | -| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 `formula-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` | -| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 `lookup-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` | -| 表单提交 | `+form-submit` | 先读 `lark-base-form-detail.md` 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 `lark-base-form-submit.md` | -| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读对应 form-questions reference | -| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 `lark-base-form-detail.md`;删除前确认目标表单 | -| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 `lark-base-dashboard.md`;组件 `data_config` 读 `dashboard-block-data-config.md`;读取图表计算结果用 `+dashboard-block-get-data` | -| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`;list/get/enable/disable 只处理 workflow ID 与启停状态 | -| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 `lark-base-role-guide.md`;角色 create/update 或解读完整配置再读权限 JSON SSOT `role-config.md`;系统角色不可删除;关闭高级权限会影响自定义角色 | +| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 | +| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 | +| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) | +| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` | +| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` | +| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) | +| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) | +| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 | +| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` | +| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 | +| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 | ## Base 心智模型 - Base 曾用名 Bitable;返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。 - 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 - 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。 -- 一次性统计、筛选、TopN 优先用 `+data-query` 或临时视图;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 +- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 - `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。 - 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。 - 跨表场景必须读取目标表结构;link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。 @@ -79,21 +79,21 @@ metadata: ## 查询与统计规则 -涉及查询、统计或判断结论时,先阅读 `references/lark-base-data-analysis-sop.md`,并遵守: +涉及查询、统计或判断结论时,先阅读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守: 1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。 -2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉明细到本地上下文再手工筛选排序。 +2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉原始记录到本地上下文再手工筛选排序。 3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。 4. 多表查询必须先确认关系字段和连接键;link 单元格里的 `record_id` 是关系键,不是用户可读答案。 5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。 -6. 一次性分析优先用 `+data-query` 或临时视图;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。 -7. `+data-query` 返回聚合结果,不返回原始记录明细;需要输出实体字段时,用聚合结果中的业务 key 或 record_id 再走 record 路径回查。 +6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。 +7. `+data-query` 可返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。 ## 写入前置规则 - 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。 - 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 -- 写字段前先读 `lark-base-field-json.md`;涉及 `formula` / `lookup` 时必须读对应 guide。 +- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。 - 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。 - 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。 - 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。 @@ -105,7 +105,7 @@ metadata: - `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。 - 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。 - `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。 -- 临时视图适合一次性筛选/排序后读取;如果筛选结果对用户后续查看有价值,应保留为持久视图并说明名称和用途。 +- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。 ## Token 与链接 @@ -125,9 +125,9 @@ metadata: ## Dashboard / Workflow / Role -- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 `dashboard-block-data-config.md`,组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 -- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`;enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 -- Role 的复杂点是权限 JSON。角色操作先读入口 `lark-base-role-guide.md`;`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT `role-config.md`。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 +- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 +- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。 +- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。 ## 常见恢复 @@ -136,9 +136,9 @@ metadata: | `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token | | `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API | | `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 | -| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 `lark-base-cell-value.md` 构造 CellValue | +| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue | | 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 | -| formula / lookup 创建失败 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 | +| formula / lookup 创建失败 | 先读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md),再按 guide 重建请求 | | `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 | | `1254104` | 批量超过 200,分批调用 | | `1254291` | 并发写冲突,串行写入并在批次间短暂等待 | @@ -146,15 +146,15 @@ metadata: ## 保留 Reference -- `lark-base-data-analysis-sop.md`:查询/统计/全局结论的选路 SOP -- `lark-base-data-query-guide.md` / `lark-base-data-query.md`:聚合查询入口 fewshot 与 DSL SSOT -- `lark-base-cell-value.md`:记录 CellValue 构造 -- `lark-base-field-json.md`:字段 JSON 构造 -- `formula-field-guide.md` / `lookup-field-guide.md`:公式与 lookup 字段 -- `lark-base-field-create.md` / `lark-base-field-update.md`:字段创建/更新命令级补充 -- `lark-base-record-upsert.md` / `lark-base-record-batch-create.md` / `lark-base-record-batch-update.md` / `lark-base-record-history-list.md`:记录写入 JSON 与历史返回解释 -- `lark-base-view-set-filter.md`:视图筛选 JSON -- `lark-base-form-detail.md` / `lark-base-form-submit.md` / `lark-base-form-questions-create.md` / `lark-base-form-questions-update.md`:表单详情、提交和复杂 JSON -- `lark-base-dashboard.md` / `dashboard-block-data-config.md` / `lark-base-dashboard-block-get-data.md`:仪表盘、组件配置与图表结果协议 -- `lark-base-workflow-guide.md` / `lark-base-workflow-schema.md`:workflow 入口与 steps JSON SSOT -- `lark-base-role-guide.md` / `role-config.md`:角色入口与权限 JSON SSOT +- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP +- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT +- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造 +- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造 +- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段 +- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充 +- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释 +- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON +- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON +- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议 +- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT +- [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT diff --git a/skills/lark-base/references/lark-base-data-analysis-sop.md b/skills/lark-base/references/lark-base-data-analysis-sop.md index 5f4bef82..62c1b2fd 100644 --- a/skills/lark-base/references/lark-base-data-analysis-sop.md +++ b/skills/lark-base/references/lark-base-data-analysis-sop.md @@ -6,14 +6,15 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、 - `+data-query`: entry guide [lark-base-data-query-guide.md](lark-base-data-query-guide.md), full DSL SSOT [lark-base-data-query.md](lark-base-data-query.md) - 视图筛选: [lark-base-view-set-filter.md](lark-base-view-set-filter.md) -- 视图排序/投影、记录读取: 先 get/list 现状,确认字段 ID、字段名、分页和投影范围 +- 记录读取: `+record-list` / `+record-search` / `+record-get`,先确认字段 ID、字段名、分页和投影范围 ## 0. Hard Rules - 全局问题不能用默认 `+record-list --limit N` 片面地回答。 - `jq` / shell / 本地代码是在个人电脑或当前运行环境中处理已返回数据,只适合小范围结果;超过 200 行默认不推荐本地统计、排序或求极值,应改用 Base 云端查询服务的 filter/sort/aggregate。 - “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义,必须在 Base 云端查询服务中完成筛选、排序或聚合。 -- `+record-search` 用于关键词检索字段的展示文本;可搜多类字段,但匹配的是文本表示(如人员命中 name),不要用它替代金额、状态、日期、空值等结构化条件。 +- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`。 +- `+record-search` 用于关键词检索字段的展示文本;金额、状态、日期、空值、关联等结构化条件继续用 `--filter-json` 表达。 - 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。 - 交付输出必须使用用户可读的真实字段值;内部 ID、`record_id`、关联记录 ID、open_id、编码字段只可作为连接键或定位键,不能替代最终输出,除非用户明确要求输出这些键值。 - 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。 @@ -22,39 +23,160 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、 | 用户意图 | 首选路径 | 关键规则 | | --- | --- | --- | -| 看几条、预览、示例 | `+record-list --limit N` | 保持局部语义;不要推广为全局结论 | +| 看几条、预览、示例 | `+record-list --limit N --field-id ...` | 保持局部语义;不要推广为全局结论 | | 已知 `record_id` | `+record-get` | 直接读取;不要 search/list 反查 | -| 明确关键词 | `+record-search` | 按字段展示文本命中;使用 `search_fields` 限定匹配范围、`select_fields` 投影降低返回内容 token 量;不要把文本检索当作结构化关联解析 | -| 按条件找明细记录 | 先创建临时视图设置筛选和可见字段,再用 `+record-list --view-id` 读取 | 条件字段来自 `+field-list`;不要先读全表再本地过滤 | -| 排序 / TopN 原始记录 | 临时视图 filter/sort/projection -> `+record-list --view-id --limit N` | 最高/最新降序,最低/最早升序 | +| 明确关键词 | `+record-search --keyword ... --search-field ... --field-id ...` | 必须显式指定 `--search-field`;可叠加 `--filter-json` | +| 按条件找原始记录 | `+record-list --filter-json ...` | `filter-json` 与视图筛选结构一致,支持文本、数字、日期、选项、人员、群组、关联等值 | +| 排序 / TopN 原始记录 | `+record-list --filter-json ... --sort-json ... --limit N` | 最高/最新用 `desc:true`,最低/最早用 `desc:false`;数组顺序表达优先级;最多 10 个排序条件 | | 聚合 / 分组 / 分组排序 | `+data-query` | 使用 filters/dimensions/measures/sort/limit | -| 聚合后输出实体字段 | `+data-query` 得到业务 key -> record 路径回查明细 | `+data-query` 不返回原始记录或 link 明细;聚合结果中的 key 需要再解析成用户要求字段 | -| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段,并在回答用结果中合并展示 | -| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询优先沉淀为持久视图 | +| 聚合后输出逐条记录 | `+data-query` 得到业务 key 或候选字段组合 -> `+record-list --filter-json` / `+record-get` 回查 | `+data-query` 维度行按字段组合去重且不返回 `record_id` | +| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段 | +| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询可沉淀为持久视图 | ## 2. Execution Patterns -### 2.1 结构化明细与 TopN +### 2.1 结构化原始记录与 TopN -使用视图路径: +使用 `+record-list` 的 filter/sort 路径: 1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。 -2. `+view-create` 创建 grid 视图。 -3. 设置 filter/sort/visible fields。 -4. `+record-list --view-id --limit ` 读取结果。 +2. 筛选只用 `--filter-json` 或 `--filter-json @file`。 +3. 排序用 `--sort-json`。 +4. `--field-id` 做最小投影,`--limit` 控制返回数量。 -不要从未筛选、未排序的全表输出中手动挑选。一次性查询可用临时视图;如果这个筛选/排序结果对用户后续查看有价值,应保留为持久视图,不要删除,并告知用户视图名称和用途。筛选 JSON 见 view-set-filter reference;排序和可见字段配置先读取现状,再按目标字段、顺序和排序方向改写。 +Example: string/number 条件 + TopN: -### 2.2 聚合分析与 TopN +```bash +lark-cli base +record-list \ + --base-token \ + --table-id \ + --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"],["Score",">=",80]]}' \ + --sort-json '[{"field":"Updated","desc":true}]' \ + --field-id Name \ + --field-id Title \ + --field-id Score \ + --limit 20 +``` + +Example: 复杂筛选从文件读取: + +```bash +lark-cli base +record-list \ + --base-token \ + --table-id \ + --filter-json @filter.json \ + --sort-json '[{"field":"Priority","desc":true}]' \ + --field-id Name \ + --field-id Tags \ + --limit 50 +``` + +`filter-json` 与视图筛选结构一致。下面只列常用 fewshot;字段类型、operator、value 形状拿不准,或需要人员、群组、关联、空值、地理位置、formula / lookup 等完整筛选时,先读 [lark-base-view-set-filter.md](lark-base-view-set-filter.md),再把同样的 filter JSON 传给 `--filter-json`。 + +文本 `==`:字段值等于目标文本。 +```json +{"logic":"and","conditions":[["Title","==","Launch plan"]]} +``` + +文本包含 / like:文本字段包含目标片段;operator 写 `intersects`。 +```json +{"logic":"and","conditions":[["Title","intersects","urgent"]]} +``` + +数字 `==`:字段值等于目标数字。 +```json +{"logic":"and","conditions":[["Score","==",95]]} +``` + +日期 `==`:字段值等于目标日期;datetime / created_at / updated_at 用 `ExactDate(...)`。 +```json +{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]} +``` + +选项 `==`:字段值匹配单个选项;选项值使用选项名数组,单个选项也写数组。 +```json +{"logic":"and","conditions":[["Priority","==",["P0"]]]} +``` + +选项 `intersects`:字段值与给定选项集合有交集,常用于多选或“命中任一选项”。 +```json +{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]} +``` + +`--sort-json` 传排序数组,数组顺序就是优先级,`desc:true` 为降序,`desc:false` 为升序,最多 10 个排序条件。 + +### 2.2 关键词检索后叠加结构化条件 + +使用 `+record-search` 做关键词命中,结构化条件仍用 `--filter-json` 下推: + +```bash +lark-cli base +record-search \ + --base-token \ + --table-id \ + --keyword Alice \ + --search-field Name \ + --filter-json '{"logic":"and","conditions":[["Status","!=","Done"]]}' \ + --sort-json '[{"field":"Updated","desc":true}]' \ + --field-id Name \ + --field-id Status \ + --limit 20 +``` + +不要把 `+record-search` 当成金额、状态、日期、空值、关联字段的结构化筛选入口;这些条件继续写成 `--filter-json`。 + +### 2.3 聚合分析与 TopN 使用 `+data-query`: - 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。 -- `pagination.limit` 是 Base 云端查询服务中的聚合结果限制,不是本地分页扫描。 -- 需要输出明细或用户可读字段时,先拿业务 key,再用 record 路径精确回查。 +- `pagination.limit` 是 Base 云端查询服务中的结果限制,不是本地分页扫描。 - 常用聚合 fewshot 先读 [lark-base-data-query-guide.md](lark-base-data-query-guide.md);字段类型、日期 value、DSL shape 以 [lark-base-data-query.md](lark-base-data-query.md) 为准。 +- `+data-query` 可返回聚合结果或维度字段行;维度字段行按字段组合去重且不返回 `record_id`,不能当逐条原始记录结果使用。 +- 需要输出逐条记录、记录定位或完整行级字段时,先用 `+data-query` 得到业务 key、分组值或候选字段组合,再用 `+record-list --filter-json` / `+record-get` 回查。 -### 2.3 关系查询与回查 +Example: 分组计数: + +```bash +lark-cli base +data-query \ + --base-token \ + --dsl '{"datasource":{"type":"table","table":{"tableId":""}},"dimensions":[{"field_name":"Status","alias":"status"}],"measures":[{"field_name":"Status","aggregation":"count","alias":"count"}],"shaper":{"format":"flat"}}' +``` + +Example: 过滤后汇总并取 TopN: + +```bash +lark-cli base +data-query \ + --base-token \ + --dsl '{"datasource":{"type":"table","table":{"tableId":""}},"dimensions":[{"field_name":"Owner","alias":"owner"}],"measures":[{"field_name":"Amount","aggregation":"sum","alias":"total_amount"}],"filters":{"type":1,"conjunction":"and","conditions":[{"field_name":"Status","operator":"is","value":["Done"]}]},"sort":[{"field_name":"total_amount","order":"desc"}],"pagination":{"limit":10},"shaper":{"format":"flat"}}' +``` + +### 2.4 视图化与复用 + +一次性查询先用 `+record-list` / `+record-search` 的 filter/sort 验证。需要用户长期打开、共享或复用时,再把同一套 filter/sort 沉淀为视图。 + +Example: 将已验证的筛选排序写入视图: + +```bash +lark-cli base +view-set-filter \ + --base-token \ + --table-id \ + --view-id \ + --json @filter.json + +lark-cli base +view-set-sort \ + --base-token \ + --table-id \ + --view-id \ + --json '{"sort_config":[{"field":"Priority","desc":true}]}' +``` + +手动配置和视图配置的优先级: + +1. `--filter-json` 覆盖 `--view-id` 保存的 view filter JSON。 +2. `--sort-json` 覆盖 `--view-id` 保存的 view sort config。 +3. 没有手动 filter/sort 时,`--view-id` 使用视图自身保存的 filter/sort。 + +### 2.5 关系查询与回查 - link 单元格通常是关联表 `record_id` 数组,不是用户可读内容,只是连接键。 - 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。 @@ -71,17 +193,17 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、 - `+record-list` 默认页、固定 `--limit`、本地 `jq`、shell 管道、手工浏览输出,都只覆盖已读取范围;超过 200 行不要把本地处理当作推荐路径。 - `has_more=true`、存在下一页 offset/page token、或返回行数等于 page size,都表示可能还有未读取数据。 -- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。 -- 必须全量导出时,按 CLI 分页语义串行翻页;不要并发调用 `+record-list`。 +- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `+data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。 +- 必须全量导出时,按 `+record-list` 分页语义串行翻页;不要并发调用 `+record-list`。 ## 4. Final Answer Check 形成交付输出前必须能确认: -- 问题范围是局部样例、单点定位、全局明细、聚合分析、多表关联,还是查询后写入。 +- 问题范围是局部样例、单点定位、全局原始记录、聚合分析、多表关联,还是查询后写入。 - 筛选、排序、聚合是否发生在 Base 云端查询服务中,而不是本地 `jq` / shell 中。 - 如果使用 `jq` / shell,本地输入是否是 200 行以内的小范围结果;超过 200 行是否已改用 Base 云端查询服务查询。 -- 如果使用 `+record-list`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。 +- 如果使用 `+record-list` / `+record-search`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。 - 如果涉及关系查询,是否按 `record_id` 或业务 key 精确回查,交付输出是否来自关联表真实字段。 - 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。 diff --git a/skills/lark-base/references/lark-base-data-query-guide.md b/skills/lark-base/references/lark-base-data-query-guide.md index 80a8db91..d20bf4db 100644 --- a/skills/lark-base/references/lark-base-data-query-guide.md +++ b/skills/lark-base/references/lark-base-data-query-guide.md @@ -14,7 +14,7 @@ Use `+data-query` when the user asks for server-side: - sorted Top N or Bottom N - global statistical conclusions -Do not use `+data-query` for raw record details. Use record commands for row-level output. +`+data-query` can return dimension field rows, but those rows are grouped by dimension values and do not include `record_id`. Use `+record-list`, `+record-search`, or `+record-get` for row-level output, record identity, or full raw record details. ## Common Fewshots diff --git a/skills/lark-base/references/lark-base-data-query.md b/skills/lark-base/references/lark-base-data-query.md index 3d1edd7a..26c6e2e0 100644 --- a/skills/lark-base/references/lark-base-data-query.md +++ b/skills/lark-base/references/lark-base-data-query.md @@ -54,7 +54,7 @@ lark-cli base +data-query \ "shaper": {"format": "flat"} }' -# 聚合后如需读取明细,先让 data-query 返回可回查的业务 key +# 聚合或维度查询后如需读取逐条记录,先让 data-query 返回可回查的业务 key lark-cli base +data-query \ --base-token MAGObxxxxx \ --dsl '{ @@ -419,16 +419,16 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称 ## 与记录读取组合 -`+data-query` 不返回原始记录或 link 字段明细。需要输出聚合结果对应的原始记录字段、展示值或关联表字段时,按以下方式组合: +`+data-query` 可返回聚合结果,也可在只传 `dimensions` 时返回维度字段行;这些维度行按字段组合去重,不包含 `record_id`,不能等同于逐条原始记录。需要输出聚合结果对应的原始记录字段、展示值、记录定位信息或关联表字段时,按以下方式组合: -1. 用 `+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN,得到业务 key、分组值或候选范围。 -2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取明细字段。 -3. 如果拿到的是结构化业务 key(例如编号、状态、日期、金额等),优先创建临时视图做精确过滤后再 `+record-list --view-id` 读取;不要用 `+record-search` 代替结构化条件。 +1. 用 `+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN,得到业务 key、分组值或候选字段组合。 +2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取逐条记录字段。 +3. 如果拿到的是结构化业务 key(例如编号、状态、日期、金额等),用 `+record-list --filter-json` 做精确过滤后读取;不要用 `+record-search` 代替结构化条件。 4. 只有候选条件本身是文本展示值关键词时,才使用 `+record-search`,并用 `search_fields` 限定范围、`select_fields` 做投影。 5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。 6. 最终回答业务字段,不要把内部 `record_id` 当作用户可读答案。 -不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量明细导出时回到 data analysis SOP 的 record 分页规则。 +不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量原始记录导出时回到 data analysis SOP 的 `+record-list` 分页规则。 ## 坑点 @@ -447,6 +447,6 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称 - [lark-base](../SKILL.md) — 多维表格全部命令 - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 -- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、record 明细回查和关系查询 SOP +- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、`+record-list` / `+record-search` 回查和关系查询 SOP - [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范 - [lark-base-field-json.md](lark-base-field-json.md) — 字段类型与 JSON 结构