mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
5 Commits
feat/sidec
...
v1.0.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add card message format support (#1218)
|
||||
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Optimize base skill references (#1171)
|
||||
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
@@ -964,6 +989,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
|
||||
14
cmd/root.go
14
cmd/root.go
@@ -48,20 +48,6 @@ EXAMPLES:
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
||||
// URL), all outgoing requests are rewritten to the sidecar address. The
|
||||
// interceptor strips placeholder credentials, injects proxy headers, and
|
||||
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
||||
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
||||
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -47,17 +46,15 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host[:port] for URL rewriting
|
||||
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
@@ -133,13 +130,8 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
||||
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
||||
scheme := i.sidecarScheme
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
req.URL.Scheme = scheme
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
|
||||
@@ -7,13 +7,11 @@ package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
@@ -99,54 +97,6 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
||||
// rewrites the request to https://<remote-host>, while still preserving the
|
||||
// original target and signing the request.
|
||||
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if req.URL.Scheme != "https" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
||||
}
|
||||
if req.URL.Host != "sidecar.mycorp.com" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
||||
}
|
||||
// Original target still preserved for the sidecar to forward upstream.
|
||||
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
// Request is still signed.
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
||||
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
||||
// https, never silently downgrading a remote sidecar to plaintext http.
|
||||
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
||||
t.Setenv(envvars.CliProxyKey, "key")
|
||||
|
||||
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
||||
si, ok := ic.(*Interceptor)
|
||||
if !ok || si == nil {
|
||||
t.Fatalf("expected *Interceptor, got %T", ic)
|
||||
}
|
||||
if si.sidecarScheme != "https" {
|
||||
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
||||
}
|
||||
if si.sidecarHost != "sidecar.mycorp.com" {
|
||||
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const (
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
|
||||
// Content safety scanning mode
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.45",
|
||||
"version": "1.0.46",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <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) {
|
||||
|
||||
@@ -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 <base_token> --table-id <table_id> --limit 50",
|
||||
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
248
shortcuts/base/record_query.go
Normal file
248
shortcuts/base/record_query.go
Normal file
@@ -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)
|
||||
}
|
||||
161
shortcuts/base/record_query_test.go
Normal file
161
shortcuts/base/record_query_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <base_token> --table-id <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 <base_token> --table-id <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) {
|
||||
|
||||
@@ -50,11 +50,12 @@ var cardChartTypeNames = map[string]string{
|
||||
type interactiveConverter struct{}
|
||||
|
||||
func (interactiveConverter) Convert(ctx *ConvertContext) string {
|
||||
return convertCard(ctx.RawContent)
|
||||
return convertCard(ctx.RawContent, ctx.Mentions)
|
||||
}
|
||||
|
||||
// convertCard converts a raw interactive/card message content JSON to human-readable string.
|
||||
func convertCard(raw string) string {
|
||||
// mentions is the raw mentions array from the API response; pass nil when not available.
|
||||
func convertCard(raw string, mentions []interface{}) string {
|
||||
var parsed cardObj
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return "[interactive card]"
|
||||
@@ -63,11 +64,19 @@ func convertCard(raw string) string {
|
||||
// raw_card_content format: outer JSON has "json_card" string field
|
||||
if jsonCard, ok := parsed["json_card"].(string); ok {
|
||||
c := &cardConverter{mode: cardModeConcise}
|
||||
if att, ok := parsed["json_attachment"].(string); ok && att != "" {
|
||||
var attObj cardObj
|
||||
if json.Unmarshal([]byte(att), &attObj) == nil {
|
||||
c.attachment = attObj
|
||||
switch att := parsed["json_attachment"].(type) {
|
||||
case string:
|
||||
if att != "" {
|
||||
var attObj cardObj
|
||||
if json.Unmarshal([]byte(att), &attObj) == nil {
|
||||
c.attachment = attObj
|
||||
}
|
||||
}
|
||||
case cardObj:
|
||||
c.attachment = att
|
||||
}
|
||||
if len(mentions) > 0 {
|
||||
c.mentionsByKey = buildMentionsByKey(mentions)
|
||||
}
|
||||
schema := 0
|
||||
if s, ok := parsed["card_schema"].(float64); ok {
|
||||
@@ -84,6 +93,22 @@ func convertCard(raw string) string {
|
||||
return convertLegacyCard(parsed)
|
||||
}
|
||||
|
||||
// buildMentionsByKey indexes the mentions array by key for O(1) lookup in convertAt.
|
||||
func buildMentionsByKey(mentions []interface{}) map[string]map[string]interface{} {
|
||||
m := make(map[string]map[string]interface{}, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, _ := item["key"].(string)
|
||||
if key != "" {
|
||||
m[key] = item
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ── Legacy converter ──────────────────────────────────────────────────────────
|
||||
|
||||
func convertLegacyCard(parsed cardObj) string {
|
||||
@@ -158,8 +183,9 @@ func legacyExtractTexts(elements []interface{}, out *[]string) {
|
||||
// ── CardConverter ─────────────────────────────────────────────────────────────
|
||||
|
||||
type cardConverter struct {
|
||||
mode cardMode
|
||||
attachment cardObj
|
||||
mode cardMode
|
||||
attachment cardObj
|
||||
mentionsByKey map[string]map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
|
||||
@@ -1403,26 +1429,52 @@ func (c *cardConverter) convertAt(prop cardObj) string {
|
||||
}
|
||||
userName := ""
|
||||
actualUserID := ""
|
||||
fromMentions := false
|
||||
if c.attachment != nil {
|
||||
if atUsers, ok := c.attachment["at_users"].(cardObj); ok {
|
||||
if userInfo, ok := atUsers[userID].(cardObj); ok {
|
||||
userName, _ = userInfo["content"].(string)
|
||||
actualUserID, _ = userInfo["user_id"].(string)
|
||||
// When the backend populates mention_key (raw_card_content path), use
|
||||
// mentions[] for the canonical name and the reading-app open_id, which is
|
||||
// more accurate than the origKey-stored user_id in at_users.
|
||||
if mentionKey, _ := userInfo["mention_key"].(string); mentionKey != "" {
|
||||
if mention, ok := c.mentionsByKey[mentionKey]; ok {
|
||||
if name, _ := mention["name"].(string); name != "" {
|
||||
userName = name
|
||||
}
|
||||
if id := extractMentionOpenId(mention["id"]); id != "" {
|
||||
actualUserID = id
|
||||
fromMentions = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if userName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
if actualUserID != "" {
|
||||
return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID)
|
||||
label := "user_id"
|
||||
if fromMentions {
|
||||
label = "open_id"
|
||||
}
|
||||
return fmt.Sprintf("@%s(%s:%s)", userName, label, actualUserID)
|
||||
}
|
||||
return fmt.Sprintf("@%s(open_id:%s)", userName, userID)
|
||||
}
|
||||
return "@" + userName
|
||||
if fromMentions && actualUserID != "" {
|
||||
return fmt.Sprintf("@%s(%s)", userName, actualUserID)
|
||||
}
|
||||
return fmt.Sprintf("@%s(%s)", userName, userID)
|
||||
}
|
||||
if c.mode == cardModeDetailed {
|
||||
if actualUserID != "" {
|
||||
return fmt.Sprintf("@user(user_id:%s)", actualUserID)
|
||||
label := "user_id"
|
||||
if fromMentions {
|
||||
label = "open_id"
|
||||
}
|
||||
return fmt.Sprintf("@user(%s:%s)", label, actualUserID)
|
||||
}
|
||||
return fmt.Sprintf("@user(open_id:%s)", userID)
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ func newTestCardConverter(mode cardMode) *cardConverter {
|
||||
|
||||
func TestConvertCard(t *testing.T) {
|
||||
rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}`
|
||||
got := convertCard(rawCard)
|
||||
got := convertCard(rawCard, nil)
|
||||
want := "<card title=\"Card Title\">\nhello\n[Open](https://example.com)\n</card>"
|
||||
if got != want {
|
||||
t.Fatalf("convertCard(json_card) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}`
|
||||
gotLegacy := convertCard(legacy)
|
||||
gotLegacy := convertCard(legacy, nil)
|
||||
wantLegacy := "**Legacy Card**\nlegacy body"
|
||||
if gotLegacy != wantLegacy {
|
||||
t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy)
|
||||
@@ -243,6 +243,75 @@ func TestCardConverterMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAtWithMentions(t *testing.T) {
|
||||
mentions := []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_6b64bef911a5a3ea763df8ffd9258f59",
|
||||
"name": "燕忠毅",
|
||||
},
|
||||
}
|
||||
attachment := cardObj{
|
||||
"at_users": cardObj{
|
||||
"cde8a6c8": cardObj{
|
||||
"user_id": "754700000001",
|
||||
"content": "燕忠毅",
|
||||
"mention_key": "@_user_1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Concise mode: should show @Name(open_id) when mention resolves.
|
||||
concise := &cardConverter{
|
||||
mode: cardModeConcise,
|
||||
attachment: attachment,
|
||||
mentionsByKey: buildMentionsByKey(mentions),
|
||||
}
|
||||
if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(ou_6b64bef911a5a3ea763df8ffd9258f59)" {
|
||||
t.Fatalf("convertAt(concise with mentions) = %q", got)
|
||||
}
|
||||
|
||||
// Detailed mode: label should be open_id when resolved from mentions.
|
||||
detailed := &cardConverter{
|
||||
mode: cardModeDetailed,
|
||||
attachment: attachment,
|
||||
mentionsByKey: buildMentionsByKey(mentions),
|
||||
}
|
||||
if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" {
|
||||
t.Fatalf("convertAt(detailed with mentions) = %q", got)
|
||||
}
|
||||
|
||||
// No mention_key: falls back to at_users.user_id with user_id label (existing behavior).
|
||||
noMentionKey := &cardConverter{
|
||||
mode: cardModeDetailed,
|
||||
attachment: cardObj{
|
||||
"at_users": cardObj{
|
||||
"ou_at": cardObj{"content": "Bob", "user_id": "u_bob"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := noMentionKey.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" {
|
||||
t.Fatalf("convertAt(fallback no mention_key) = %q", got)
|
||||
}
|
||||
|
||||
// mention_key present but mentionsByKey nil: still falls back gracefully.
|
||||
nilMentions := &cardConverter{
|
||||
mode: cardModeDetailed,
|
||||
attachment: cardObj{
|
||||
"at_users": cardObj{
|
||||
"cde8a6c8": cardObj{
|
||||
"user_id": "754700000001",
|
||||
"content": "燕忠毅",
|
||||
"mention_key": "@_user_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(user_id:754700000001)" {
|
||||
t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardConverterExtractTextHelpers(t *testing.T) {
|
||||
c := newTestCardConverter(cardModeDetailed)
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ type ContentConverter interface {
|
||||
type ConvertContext struct {
|
||||
RawContent string
|
||||
MentionMap map[string]string
|
||||
// Mentions is the raw mentions array from the API response.
|
||||
// Used by interactive card converter to resolve @user references via mention_key.
|
||||
Mentions []interface{}
|
||||
// MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API.
|
||||
// For other message types these can be zero values.
|
||||
MessageID string
|
||||
@@ -93,6 +96,7 @@ func FormatEventMessage(msgType, rawContent, messageID string, mentions []interf
|
||||
content := ConvertBodyContent(msgType, &ConvertContext{
|
||||
RawContent: rawContent,
|
||||
MentionMap: BuildMentionKeyMap(mentions),
|
||||
Mentions: mentions,
|
||||
MessageID: messageID,
|
||||
})
|
||||
|
||||
@@ -153,6 +157,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
|
||||
content = ConvertBodyContent(msgType, &ConvertContext{
|
||||
RawContent: rawContent,
|
||||
MentionMap: BuildMentionKeyMap(mentions),
|
||||
Mentions: mentions,
|
||||
MessageID: messageId,
|
||||
Runtime: runtime,
|
||||
SenderNames: nameCache,
|
||||
|
||||
@@ -320,6 +320,7 @@ func FormatMergeForwardSubTree(parentID string, childrenMap map[string][]map[str
|
||||
content = ConvertBodyContent(msgType, &ConvertContext{
|
||||
RawContent: rawContent,
|
||||
MentionMap: BuildMentionKeyMap(mentions),
|
||||
Mentions: mentions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -325,3 +325,29 @@ func TestMergeForwardConverterWithRuntime(t *testing.T) {
|
||||
t.Fatalf("mergeForwardConverter.Convert(runtime) = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMergeForwardSubTreeInteractiveCardUsesMentions(t *testing.T) {
|
||||
cardContent := `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"at\",\"property\":{\"userID\":\"cde8a6c8\"}}]}}","json_attachment":"{\"at_users\":{\"cde8a6c8\":{\"user_id\":\"754700000001\",\"content\":\"Alice\",\"mention_key\":\"@_user_1\"}}}"}`
|
||||
items := []map[string]interface{}{
|
||||
{
|
||||
"message_id": "om_card",
|
||||
"msg_type": "interactive",
|
||||
"create_time": "1710500000000",
|
||||
"sender": map[string]interface{}{"name": "Sender"},
|
||||
"body": map[string]interface{}{"content": cardContent},
|
||||
"mentions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_real_open_id",
|
||||
"name": "Alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
children := BuildMergeForwardChildrenMap(items, "om_root")
|
||||
got := FormatMergeForwardSubTree("om_root", children)
|
||||
if !strings.Contains(got, "@Alice(ou_real_open_id)") {
|
||||
t.Fatalf("FormatMergeForwardSubTree(interactive card) = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,11 +161,6 @@ func TestValidateProxyAddr(t *testing.T) {
|
||||
"http://gateway.docker.internal:16384",
|
||||
// trailing slash is tolerated
|
||||
"http://127.0.0.1:8080/",
|
||||
// https: any valid host (including remote, cross-machine) is allowed
|
||||
"https://127.0.0.1:16384",
|
||||
"https://sidecar.mycorp.com",
|
||||
"https://sidecar.mycorp.com:8443",
|
||||
"https://sidecar.corp.internal:443/",
|
||||
}
|
||||
for _, addr := range valid {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
@@ -247,8 +242,6 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
"http://user@127.0.0.1:16384",
|
||||
"http://user:pass@127.0.0.1:16384",
|
||||
"http://127.0.0.1@attacker.com:16384",
|
||||
"https://x@evil.com",
|
||||
"https://user:pass@sidecar.mycorp.com",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
@@ -266,99 +259,23 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPSAllowed pins the contract: https addresses are
|
||||
// accepted, including a remote sidecar on another machine. TLS provides
|
||||
// confidentiality over the network and the HMAC signature provides
|
||||
// integrity/auth, so cross-machine https is supported.
|
||||
func TestValidateProxyAddr_HTTPSAllowed(t *testing.T) {
|
||||
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
|
||||
// rejected explicitly (not lumped into a generic "bad scheme" error) because
|
||||
// the interceptor hardcodes http and would silently downgrade an https URL
|
||||
// otherwise. The message must mention https so users understand why their
|
||||
// perfectly-looking config is refused.
|
||||
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://127.0.0.1:16384", // same-host over TLS
|
||||
"https://127.0.0.1:16384",
|
||||
"https://sidecar.corp.internal:443",
|
||||
"https://sidecar.mycorp.com", // remote, no explicit port
|
||||
"https://sidecar.mycorp.com:8443",
|
||||
} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPRemoteRejected: plaintext http to a non-same-host
|
||||
// address stays rejected — a remote sidecar must use https.
|
||||
func TestValidateProxyAddr_HTTPRemoteRejected(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"http://sidecar.mycorp.com",
|
||||
"http://sidecar.mycorp.com:8080",
|
||||
"http://10.0.0.1:16384",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
|
||||
continue
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "https") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should point to https/same-host, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyScheme: scheme is https only for https:// addresses, http otherwise.
|
||||
// Case-insensitive: HTTPS:// must resolve to https, otherwise a remote sidecar
|
||||
// would silently downgrade to plaintext http (see ProxyScheme doc).
|
||||
func TestProxyScheme(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://sidecar.mycorp.com": "https",
|
||||
"https://127.0.0.1:16384": "https",
|
||||
"http://127.0.0.1:16384": "http",
|
||||
"127.0.0.1:16384": "http",
|
||||
// case-insensitive scheme
|
||||
"HTTPS://sidecar.mycorp.com": "https",
|
||||
"Https://sidecar.mycorp.com": "https",
|
||||
"HtTp://127.0.0.1:16384": "http",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := ProxyScheme(in); got != want {
|
||||
t.Errorf("ProxyScheme(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_SchemeCaseInsensitive: mixed-case scheme must follow the
|
||||
// same policy as lower-case — HTTPS accepted (remote allowed), HTTP remote
|
||||
// rejected — so case can't be used to bypass the plaintext same-host rule.
|
||||
func TestValidateProxyAddr_SchemeCaseInsensitive(t *testing.T) {
|
||||
for _, addr := range []string{"HTTPS://sidecar.mycorp.com", "Https://sidecar.corp.internal:443"} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
for _, addr := range []string{"HtTp://sidecar.mycorp.com", "HTTP://10.0.0.1:16384"} {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_IPv6HTTPS pins IPv6 https forms.
|
||||
func TestValidateProxyAddr_IPv6HTTPS(t *testing.T) {
|
||||
for _, addr := range []string{"https://[::1]:443", "https://[::1]"} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_RejectsQueryFragment: a proxy address must not carry a
|
||||
// query or fragment, for either scheme.
|
||||
func TestValidateProxyAddr_RejectsQueryFragment(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://sidecar.mycorp.com?x=1",
|
||||
"https://sidecar.mycorp.com#frag",
|
||||
"http://127.0.0.1:16384?x=1",
|
||||
} {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
|
||||
if !strings.Contains(err.Error(), "https") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,10 +289,6 @@ func TestProxyHost(t *testing.T) {
|
||||
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
|
||||
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
|
||||
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
|
||||
// https forms (remote sidecar)
|
||||
{"https://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||
{"https://sidecar.mycorp.com:8443/", "sidecar.mycorp.com:8443"},
|
||||
{"HTTPS://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
// Package sidecar defines the wire protocol shared between the CLI client
|
||||
// (running inside a sandbox) and the auth sidecar proxy (running in a
|
||||
// trusted environment). Communication uses HTTP for a same-host sidecar, or
|
||||
// HTTPS (TLS) for a remote sidecar.
|
||||
// trusted environment). Communication uses plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -104,31 +103,32 @@ func isSameHost(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// errNotSameHost is the shared error returned when a plaintext (http) sidecar
|
||||
// address does not resolve to the same physical host as the sandbox. Kept in
|
||||
// one place so tests can look for a stable marker.
|
||||
// errNotSameHost is the shared error returned when the sidecar address does
|
||||
// not resolve to the same physical host as the sandbox. Kept in one place so
|
||||
// tests can look for a stable marker.
|
||||
func errNotSameHost(addr string) error {
|
||||
return fmt.Errorf("invalid proxy address %q: a plaintext (http) sidecar must be "+
|
||||
"loopback (127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
|
||||
"(127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
"(localhost, host.docker.internal, host.containers.internal, "+
|
||||
"host.lima.internal, gateway.docker.internal). "+
|
||||
"For a remote sidecar on another machine, use an https:// address instead", addr)
|
||||
"The sidecar must run on the same physical machine as the sandbox — "+
|
||||
"cross-machine deployment is not a sidecar and is not supported", addr)
|
||||
}
|
||||
|
||||
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
|
||||
// Accepted formats:
|
||||
// - https://host[:port] (remote sidecar; cross-machine allowed)
|
||||
// - http://host:port (plaintext; same-host only)
|
||||
// - host:port (bare address, treated as plaintext http; same-host only)
|
||||
// - http://host:port
|
||||
// - host:port (bare address, treated as http)
|
||||
//
|
||||
// Scheme policy:
|
||||
// - https:// — any valid host is allowed, including a remote central sidecar
|
||||
// on another machine. TLS provides confidentiality over the untrusted
|
||||
// network; the per-request HMAC signature provides integrity/auth.
|
||||
// - http:// (or bare host:port) — plaintext, allowed only when the host is
|
||||
// loopback (127.0.0.1 / ::1) or a recognized same-host alias (a virtual
|
||||
// same-host bridge that stays on the physical machine). For a remote
|
||||
// sidecar, use an https:// address instead.
|
||||
// Host must be loopback or in sameHostAliases. The sidecar pattern is
|
||||
// inherently same-machine; cross-machine deployment is a different product
|
||||
// and is not supported by this feature.
|
||||
//
|
||||
// https:// is rejected because sidecar is a same-host pattern: loopback
|
||||
// and virtual same-host bridges don't traverse any untrusted medium, so
|
||||
// TLS adds no security. Cross-machine deployment is out of scope (see the
|
||||
// host constraint above), so there is no scenario today where https
|
||||
// provides a real benefit over http on loopback.
|
||||
//
|
||||
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
|
||||
// does not use basic auth, and the syntactic slot exists only as a phishing
|
||||
@@ -140,11 +140,11 @@ func ValidateProxyAddr(addr string) error {
|
||||
return fmt.Errorf("proxy address is empty")
|
||||
}
|
||||
|
||||
// Bare host:port (no scheme) — treated as plaintext http, so same-host only.
|
||||
// Bare host:port (no scheme) — validate as a net address.
|
||||
if !strings.Contains(addr, "://") {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http(s)://host[:port]", addr)
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
|
||||
}
|
||||
if host == "" || port == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
|
||||
@@ -159,47 +159,33 @@ func ValidateProxyAddr(addr string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
|
||||
}
|
||||
// userinfo (user:pass@) is rejected unconditionally (phishing vector).
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
|
||||
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
|
||||
"no security; cross-machine deployment is out of scope", addr)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: missing host", addr)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return fmt.Errorf("invalid proxy address %q: query is not allowed", addr)
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return fmt.Errorf("invalid proxy address %q: fragment is not allowed", addr)
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
// Remote sidecar over TLS. Cross-machine is allowed: https provides
|
||||
// confidentiality over the network and the per-request HMAC signature
|
||||
// provides integrity/authentication, so a remote central sidecar is
|
||||
// supported without exposing credentials or signing material in clear.
|
||||
return nil
|
||||
case "http":
|
||||
// Plaintext: only safe on the same physical host (loopback or a virtual
|
||||
// same-host bridge). For a remote sidecar use an https:// address.
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http or https", addr)
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
|
||||
// Input is expected to be an http:// or https:// URL like
|
||||
// "http://127.0.0.1:16384" or "https://sidecar.mycorp.com".
|
||||
// Returns the host[:port] portion for URL rewriting.
|
||||
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384".
|
||||
// Returns the host:port portion for URL rewriting.
|
||||
func ProxyHost(authProxy string) string {
|
||||
// Strip scheme
|
||||
host := authProxy
|
||||
@@ -210,19 +196,3 @@ func ProxyHost(authProxy string) string {
|
||||
host = strings.TrimRight(host, "/")
|
||||
return host
|
||||
}
|
||||
|
||||
// ProxyScheme returns the URL scheme the CLI must use when routing to the
|
||||
// sidecar: "https" for a TLS (remote) sidecar, otherwise "http" (same-host
|
||||
// plaintext). Input is a value already accepted by ValidateProxyAddr.
|
||||
//
|
||||
// It parses the address (rather than a case-sensitive prefix check) so the
|
||||
// result stays consistent with ValidateProxyAddr, which relies on url.Parse
|
||||
// normalizing the scheme. Otherwise "HTTPS://host" — accepted as https by
|
||||
// ValidateProxyAddr — would silently downgrade to plaintext http here,
|
||||
// breaking the "remote must use TLS" boundary.
|
||||
func ProxyScheme(authProxy string) string {
|
||||
if u, err := url.Parse(authProxy); err == nil && strings.EqualFold(u.Scheme, "https") {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
@@ -114,23 +114,18 @@ export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to o
|
||||
|
||||
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
|
||||
|
||||
- Scheme must be `http://` / `https://` (or bare `host:port`, treated as
|
||||
plaintext http).
|
||||
- `https://<any-host>` is allowed, **including a remote sidecar on another
|
||||
machine**: TLS provides confidentiality over the network and the
|
||||
per-request HMAC signature provides integrity/authentication.
|
||||
- Plaintext `http://` (and bare `host:port`) is allowed **only same-host**:
|
||||
loopback (`127.0.0.1`, `::1`) or a recognized same-host alias
|
||||
(`localhost`, `host.docker.internal`, `host.containers.internal`,
|
||||
`host.lima.internal`, `gateway.docker.internal`). For a remote sidecar,
|
||||
use an `https://` address.
|
||||
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
|
||||
today because the interceptor does not yet perform TLS; a future PR that
|
||||
wires up real TLS will relax this.
|
||||
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
|
||||
same-host aliases: `localhost`, `host.docker.internal`,
|
||||
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
|
||||
The sidecar pattern is inherently same-machine; cross-machine deployment
|
||||
is a different product (auth broker / STS) with different security
|
||||
requirements (mTLS, cert rotation, per-client keys) and is not supported
|
||||
by this feature.
|
||||
- No path, query, fragment, or `user:pass@` in the URL.
|
||||
|
||||
> Note: this demo server itself terminates plain HTTP and is meant to run
|
||||
> locally. A production **remote** sidecar must terminate TLS (its own
|
||||
> `https://` endpoint, e.g. behind a load balancer or with a real
|
||||
> certificate); the CLI-side policy above is what enables pointing at it.
|
||||
|
||||
**How auto identity detection works in sidecar mode**: on every invocation the
|
||||
CLI asks the sidecar to look up the logged-in user's `open_id` via
|
||||
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <view_id> --limit <N>` 读取结果。
|
||||
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 <base_token> \
|
||||
--table-id <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 <base_token> \
|
||||
--table-id <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 <base_token> \
|
||||
--table-id <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 <base_token> \
|
||||
--dsl '{"datasource":{"type":"table","table":{"tableId":"<table_id>"}},"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 <base_token> \
|
||||
--dsl '{"datasource":{"type":"table","table":{"tableId":"<table_id>"}},"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 <base_token> \
|
||||
--table-id <table_id> \
|
||||
--view-id <view_id> \
|
||||
--json @filter.json
|
||||
|
||||
lark-cli base +view-set-sort \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--view-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 精确回查,交付输出是否来自关联表真实字段。
|
||||
- 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 结构
|
||||
|
||||
Reference in New Issue
Block a user