From c58a58f91d8e115d28efaa37f025fea6268402c4 Mon Sep 17 00:00:00 2001 From: "wanglei.75" Date: Thu, 30 Jul 2026 17:02:57 +0800 Subject: [PATCH 1/2] feat(base): add --position and statistics number_format to dashboard-block create/update Add an optional top-level --position flag ({x,y,w,h} JSON, parsed but not coordinate-validated, passed through as a sibling of name/type/data_config) and optional statistics data_config.number_format ({formatName,precision}) with light enum + 0-9 integer validation. Both are backward compatible. Body assembly is unified in a shared buildDashboardBlockBody helper so DryRun and Execute stay isomorphic. Adds toIntStrict for strict precision parsing, focused helper/execute/dry-run tests, an E2E dry-run test, and syncs the lark-base dashboard + data-config skill references. Co-authored-by: TRAE CLI --- .../base_dashboard_layout_precision_test.go | 279 ++++++++++++++++++ shortcuts/base/base_dryrun_ops_test.go | 41 +++ shortcuts/base/dashboard_block_create.go | 20 +- shortcuts/base/dashboard_block_update.go | 17 +- shortcuts/base/dashboard_ops.go | 118 +++++--- shortcuts/base/helpers.go | 73 +++++ .../references/dashboard-block-data-config.md | 33 +++ .../references/lark-base-dashboard.md | 21 ++ ...oard_block_layout_precision_dryrun_test.go | 108 +++++++ tests/cli_e2e/base/coverage.md | 9 +- 10 files changed, 650 insertions(+), 69 deletions(-) create mode 100644 shortcuts/base/base_dashboard_layout_precision_test.go create mode 100644 tests/cli_e2e/base/base_dashboard_block_layout_precision_dryrun_test.go diff --git a/shortcuts/base/base_dashboard_layout_precision_test.go b/shortcuts/base/base_dashboard_layout_precision_test.go new file mode 100644 index 0000000000..405e4b29ea --- /dev/null +++ b/shortcuts/base/base_dashboard_layout_precision_test.go @@ -0,0 +1,279 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// ── toIntStrict ────────────────────────────────────────────────────── + +// TestToIntStrict guards the strict integer semantics used by number_format +// precision: exact integers pass (including json.Number and integral float64), +// while fractional values, strings and bools are rejected instead of coerced. +func TestToIntStrict(t *testing.T) { + for _, tc := range []struct { + name string + in interface{} + want int + wantOK bool + }{ + {"int", 3, 3, true}, + {"int64", int64(9), 9, true}, + {"json.Number integer", json.Number("2"), 2, true}, + {"json.Number fractional", json.Number("2.5"), 0, false}, + {"float64 integral", float64(4), 4, true}, + {"float64 fractional", 2.5, 0, false}, + {"string", "2", 0, false}, + {"bool", true, 0, false}, + {"nil", nil, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := toIntStrict(tc.in) + if ok != tc.wantOK || (ok && got != tc.want) { + t.Fatalf("toIntStrict(%v)=(%d,%v), want (%d,%v)", tc.in, got, ok, tc.want, tc.wantOK) + } + }) + } +} + +// ── validateNumberFormat via validateBlockDataConfig ───────────────── + +// TestValidateBlockDataConfig_NumberFormat covers the statistics number_format +// light validation: valid enum + precision pass, illegal formatName / precision +// (out of range or fractional) fail, absent field is a no-op, and non-statistics +// types ignore number_format entirely (F-5). +func TestValidateBlockDataConfig_NumberFormat(t *testing.T) { + baseStat := func(nf interface{}) map[string]interface{} { + cfg := map[string]interface{}{ + "table_name": "T", + "count_all": true, + } + if nf != nil { + cfg["number_format"] = nf + } + return cfg + } + + // UseNumber-decoded precision keeps json.Number so strict parsing applies. + decode := func(raw string) map[string]interface{} { + var m map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(raw))) + dec.UseNumber() + if err := dec.Decode(&m); err != nil { + t.Fatalf("decode %s: %v", raw, err) + } + return m + } + + t.Run("valid formatName + precision", func(t *testing.T) { + cfg := decode(`{"table_name":"T","count_all":true,"number_format":{"formatName":"dollar_rounded","precision":2}}`) + if errs := validateBlockDataConfig("statistics", cfg); len(errs) != 0 { + t.Fatalf("expected no errors, got %v", errs) + } + }) + + t.Run("precision boundaries 0 and 9", func(t *testing.T) { + for _, p := range []string{"0", "9"} { + cfg := decode(`{"table_name":"T","count_all":true,"number_format":{"precision":` + p + `}}`) + if errs := validateBlockDataConfig("statistics", cfg); len(errs) != 0 { + t.Fatalf("precision=%s expected no errors, got %v", p, errs) + } + } + }) + + t.Run("illegal formatName", func(t *testing.T) { + cfg := baseStat(map[string]interface{}{"formatName": "not_a_format"}) + errs := validateBlockDataConfig("statistics", cfg) + if !containsSubstr(errs, "formatName") { + t.Fatalf("expected formatName error, got %v", errs) + } + }) + + t.Run("precision out of range", func(t *testing.T) { + cfg := decode(`{"table_name":"T","count_all":true,"number_format":{"precision":10}}`) + errs := validateBlockDataConfig("statistics", cfg) + if !containsSubstr(errs, "precision") { + t.Fatalf("expected precision error, got %v", errs) + } + }) + + t.Run("precision fractional rejected", func(t *testing.T) { + cfg := decode(`{"table_name":"T","count_all":true,"number_format":{"precision":2.5}}`) + errs := validateBlockDataConfig("statistics", cfg) + if !containsSubstr(errs, "precision") { + t.Fatalf("expected precision error for 2.5, got %v", errs) + } + }) + + t.Run("number_format not object", func(t *testing.T) { + cfg := baseStat("digital") + errs := validateBlockDataConfig("statistics", cfg) + if !containsSubstr(errs, "number_format") { + t.Fatalf("expected number_format object error, got %v", errs) + } + }) + + t.Run("absent number_format is fine", func(t *testing.T) { + cfg := baseStat(nil) + if errs := validateBlockDataConfig("statistics", cfg); len(errs) != 0 { + t.Fatalf("expected no errors, got %v", errs) + } + }) + + t.Run("non-statistics ignores number_format (F-5)", func(t *testing.T) { + cfg := map[string]interface{}{ + "table_name": "T", + "count_all": true, + "group_by": []interface{}{map[string]interface{}{"field_name": "x", "mode": "integrated"}}, + "number_format": map[string]interface{}{"formatName": "not_a_format", "precision": 99}, + } + if errs := validateBlockDataConfig("column", cfg); len(errs) != 0 { + t.Fatalf("column type must ignore number_format, got %v", errs) + } + }) +} + +func containsSubstr(errs []string, want string) bool { + for _, e := range errs { + if strings.Contains(e, want) { + return true + } + } + return false +} + +// ── position + number_format body passthrough (Execute) ────────────── + +// TestBaseDashboardBlockExecuteCreate_PositionAndNumberFormat proves the create +// Execute path forwards the top-level position sibling and statistics +// data_config.number_format verbatim into the request body. +func TestBaseDashboardBlockExecuteCreate_PositionAndNumberFormat(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_new", "name": "Revenue", "type": "statistics"}}, + } + reg.Register(stub) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "Revenue", "--type", "statistics", + "--data-config", `{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}],"number_format":{"formatName":"dollar_rounded","precision":2}}`, + "--position", `{"x":0,"y":0,"w":6,"h":4}`, + } + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + body := decodeCapturedBody(t, stub.CapturedBody) + pos, ok := body["position"].(map[string]interface{}) + if !ok { + t.Fatalf("position missing/not object: body=%s", string(stub.CapturedBody)) + } + if toInt(pos["w"]) != 6 || toInt(pos["h"]) != 4 { + t.Fatalf("position not forwarded verbatim: %v", pos) + } + dc, _ := body["data_config"].(map[string]interface{}) + nf, ok := dc["number_format"].(map[string]interface{}) + if !ok { + t.Fatalf("number_format missing: body=%s", string(stub.CapturedBody)) + } + if nf["formatName"] != "dollar_rounded" || toInt(nf["precision"]) != 2 { + t.Fatalf("number_format not forwarded verbatim: %v", nf) + } +} + +// TestBaseDashboardBlockExecuteUpdate_Position proves the update Execute path +// forwards the top-level position sibling verbatim. +func TestBaseDashboardBlockExecuteUpdate_Position(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_a"}}, + } + reg.Register(stub) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", + "--position", `{"x":6,"y":0,"w":6,"h":4}`, + } + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + body := decodeCapturedBody(t, stub.CapturedBody) + pos, ok := body["position"].(map[string]interface{}) + if !ok { + t.Fatalf("position missing: body=%s", string(stub.CapturedBody)) + } + if toInt(pos["x"]) != 6 || toInt(pos["w"]) != 6 { + t.Fatalf("position not forwarded verbatim: %v", pos) + } +} + +// TestBaseDashboardBlockCreate_PositionNotValidated proves out-of-range / +// negative coordinates pass through unvalidated (aligns with dws grid). +func TestBaseDashboardBlockCreate_PositionNotValidated(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_new"}}, + } + reg.Register(stub) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "N", "--type", "statistics", "--data-config", `{"table_name":"T","count_all":true}`, + "--position", `{"x":-5,"y":0,"w":99,"h":0}`, + } + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("out-of-range position must pass through, got err=%v", err) + } + body := decodeCapturedBody(t, stub.CapturedBody) + pos, _ := body["position"].(map[string]interface{}) + if toInt(pos["w"]) != 99 { + t.Fatalf("position coords must pass through verbatim: %v", pos) + } +} + +// TestBaseDashboardBlockCreate_InvalidPositionJSON proves malformed position +// JSON fails consistently on the execute path. +func TestBaseDashboardBlockCreate_InvalidPositionJSON(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "N", "--type", "statistics", "--data-config", `{"table_name":"T","count_all":true}`, + "--position", `not-json`, + } + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err == nil { + t.Fatalf("expected error for malformed --position JSON") + } +} + +// TestBaseDashboardBlockCreate_NumberFormatInvalidRejected proves the statistics +// number_format validation is wired into the create command. +func TestBaseDashboardBlockCreate_NumberFormatInvalidRejected(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "N", "--type", "statistics", + "--data-config", `{"table_name":"T","count_all":true,"number_format":{"formatName":"bogus","precision":2}}`, + } + err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout) + if err == nil { + t.Fatalf("expected validation error for bad formatName") + } + if !strings.Contains(err.Error(), "formatName") || !strings.Contains(err.Error(), "data_config 校验失败") { + t.Fatalf("unexpected error: %v", err) + } +} + +func decodeCapturedBody(t *testing.T, raw []byte) map[string]interface{} { + t.Helper() + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("captured body json err=%v body=%s", err, string(raw)) + } + return body +} diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index dc7a2f9781..ec4878395a 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -5,6 +5,7 @@ package base import ( "context" + "reflect" "strings" "testing" ) @@ -402,6 +403,46 @@ func TestDryRunDashboardOps(t *testing.T) { assertDryRunContains(t, dryRunDashboardBlockDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1") } +// TestDryRunDashboardBlockPositionAndNumberFormat asserts the shared body +// builder surfaces the optional top-level position and data_config.number_format +// in the dry-run request body, and that create DryRun matches the create Execute +// body shape (isomorphism) for the same inputs. +func TestDryRunDashboardBlockPositionAndNumberFormat(t *testing.T) { + ctx := context.Background() + + rt := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "dashboard-id": "dash_1", + "block-id": "blk_1", + "name": "Revenue", + "type": "statistics", + "data-config": `{"table_name":"Orders","count_all":true,"number_format":{"formatName":"dollar_rounded","precision":2}}`, + "position": `{"x":0,"y":0,"w":6,"h":4}`, + }, + nil, + nil, + ) + + assertDryRunContains(t, dryRunDashboardBlockCreate(ctx, rt), + "POST /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks", + `"position"`, `"w":6`, `"number_format"`, `"dollar_rounded"`) + assertDryRunContains(t, dryRunDashboardBlockUpdate(ctx, rt), + "PATCH /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1", + `"position"`, `"w":6`) + + // Isomorphism: DryRun body and Execute body must be built identically. + pc := newParseCtx(rt) + dryBody, _ := buildDashboardBlockBody(pc, rt, true, false) + execBody, err := buildDashboardBlockBody(pc, rt, true, true) + if err != nil { + t.Fatalf("execute body build err=%v", err) + } + if !reflect.DeepEqual(dryBody, execBody) { + t.Fatalf("dry-run and execute bodies diverge:\n dry=%v\nexec=%v", dryBody, execBody) + } +} + func TestDryRunViewOps(t *testing.T) { ctx := context.Background() diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index 5031202c21..6bad369703 100644 --- a/shortcuts/base/dashboard_block_create.go +++ b/shortcuts/base/dashboard_block_create.go @@ -26,15 +26,19 @@ var BaseDashboardBlockCreate = common.Shortcut{ {Name: "name", Desc: "block name", Required: true}, {Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true}, {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, + {Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; x/y>=0, 1<=w<=12 and x+w<=12, h>=1; omit for server auto-layout`}, {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"}, }, Tips: []string{ `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`, + `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Revenue" --type statistics --data-config '{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}],"number_format":{"formatName":"dollar_rounded","precision":2}}'`, `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`, + `lark-cli base +dashboard-block-create --base-token --dashboard-id --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}' --position '{"x":0,"y":0,"w":6,"h":4}'`, "Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.", "data_config uses table and field names, not table_id or field_id.", "Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", + "--position is optional precise layout in a 12-col grid; omit it to let the server auto-layout. Coordinate values are not validated locally and overlaps are not server-checked. To re-tidy an existing dashboard use +dashboard-arrange instead.", "For funnel/stage charts backed by ordered helper data, set the intended group_by.sort in the initial create request; do not create first and then issue a second update just to fix sorting.", "Record the returned block_id; block update/delete/get-data commands need it.", "Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.", @@ -44,6 +48,9 @@ var BaseDashboardBlockCreate = common.Shortcut{ if runtime.Bool("no-validate") { return nil } + if err := validateDashboardBlockPosition(pc, runtime); err != nil { + return err + } raw := runtime.Str("data-config") if strings.TrimSpace(raw) == "" { // text 类型必须提供 data-config(含 text 内容) @@ -67,18 +74,7 @@ var BaseDashboardBlockCreate = common.Shortcut{ }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) - body := map[string]interface{}{} - if name := runtime.Str("name"); name != "" { - body["name"] = name - } - if t := runtime.Str("type"); t != "" { - body["type"] = t - } - if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil { - body["data_config"] = parsed - } - } + body, _ := buildDashboardBlockBody(pc, runtime, true, false) params := map[string]interface{}{} if uid := runtime.Str("user-id-type"); uid != "" { params["user_id_type"] = uid diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go index f3e38b35a3..65176a42fe 100644 --- a/shortcuts/base/dashboard_block_update.go +++ b/shortcuts/base/dashboard_block_update.go @@ -25,22 +25,29 @@ var BaseDashboardBlockUpdate = common.Shortcut{ blockIDFlag(true), {Name: "name", Desc: "new block name"}, {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, + {Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; x/y>=0, 1<=w<=12 and x+w<=12, h>=1; omit to leave layout unchanged`}, {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"}, }, Tips: []string{ `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --name "Total Sales"`, `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`, + `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --data-config '{"number_format":{"precision":0}}'`, + `lark-cli base +dashboard-block-update --base-token --dashboard-id --block-id --position '{"x":6,"y":0,"w":6,"h":4}'`, "Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.", "Use +dashboard-block-get first to inspect the current data_config before replacing nested values.", "Block type cannot be changed; delete and recreate the block to change chart type.", "data_config update merges top-level keys, but each provided key is replaced as a whole.", + "--position is optional precise layout in a 12-col grid; omit it to leave the current layout unchanged. Coordinate values are not validated locally and overlaps are not server-checked. To re-tidy an existing dashboard use +dashboard-arrange instead.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) if runtime.Bool("no-validate") { return nil } + if err := validateDashboardBlockPosition(pc, runtime); err != nil { + return err + } raw := runtime.Str("data-config") if strings.TrimSpace(raw) == "" { return nil @@ -57,15 +64,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{ }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) - body := map[string]interface{}{} - if name := runtime.Str("name"); name != "" { - body["name"] = name - } - if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil { - body["data_config"] = parsed - } - } + body, _ := buildDashboardBlockBody(pc, runtime, false, false) params := map[string]interface{}{} if uid := runtime.Str("user-id-type"); uid != "" { params["user_id_type"] = uid diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go index 0391ff51cd..4590699322 100644 --- a/shortcuts/base/dashboard_ops.go +++ b/shortcuts/base/dashboard_ops.go @@ -5,6 +5,7 @@ package base import ( "context" + "encoding/json" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -20,6 +21,71 @@ func blockIDFlag(required bool) common.Flag { return common.Flag{Name: "block-id", Desc: "dashboard block ID", Required: required} } +// buildDashboardBlockBody assembles the request body shared by the dashboard +// block create and update commands. It is the single source of truth for body +// shape so DryRun and Execute stay isomorphic across all call sites. +// +// - includeType controls whether the block type is emitted (create only). +// - The optional top-level position object is parsed as JSON and passed +// through verbatim as a sibling of name/type/data_config; coordinate values +// are NOT validated (aligns with dws grid semantics). +// - When strict is true (Execute path) a malformed data-config/position fails +// the command; when false (DryRun preview) the offending field is skipped so +// the preview still renders, matching the existing dry-run behaviour. +func buildDashboardBlockBody(pc *parseCtx, runtime *common.RuntimeContext, includeType, strict bool) (map[string]interface{}, error) { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if includeType { + if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { + body["type"] = blockType + } + } + if raw := runtime.Str("data-config"); raw != "" { + parsed, err := parseJSONObject(pc, raw, "data-config") + if err != nil { + if strict { + return nil, err + } + } else { + body["data_config"] = parsed + } + } + if raw := strings.TrimSpace(runtime.Str("position")); raw != "" { + parsed, err := parseJSONObject(pc, raw, "position") + if err != nil { + if strict { + return nil, err + } + } else { + body["position"] = parsed + } + } + return body, nil +} + +// validateDashboardBlockPosition parses the optional --position flag as a JSON +// object to fail fast on malformed input. Coordinate values (x/y/w/h) are NOT +// validated — they pass through verbatim, aligning with dws grid semantics and +// leaving overlap handling to the server. Callers must skip this when +// --no-validate is set so dry-run and execute stay consistent. +func validateDashboardBlockPosition(pc *parseCtx, runtime *common.RuntimeContext) error { + raw := strings.TrimSpace(runtime.Str("position")) + if raw == "" { + return nil + } + pos, err := parseJSONObject(pc, raw, "position") + if err != nil { + return err + } + // Rewrite the flag with canonical JSON so the parsed shape is identical + // across Validate, DryRun and Execute. + b, _ := json.Marshal(pos) + _ = runtime.Cmd.Flags().Set("position", string(b)) + return nil +} + // dryRunDashboardBase returns a base DryRunAPI with common dashboard parameters set. func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). @@ -111,18 +177,7 @@ func dryRunDashboardBlockGetData(_ context.Context, runtime *common.RuntimeConte // dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block. func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) - body := map[string]interface{}{} - if name := strings.TrimSpace(runtime.Str("name")); name != "" { - body["name"] = name - } - if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { - body["type"] = blockType - } - if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil { - body["data_config"] = parsed - } - } + body, _ := buildDashboardBlockBody(pc, runtime, true, false) params := map[string]interface{}{} if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { @@ -137,15 +192,7 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex // dryRunDashboardBlockUpdate returns a DryRunAPI for updating a dashboard block. func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) - body := map[string]interface{}{} - if name := strings.TrimSpace(runtime.Str("name")); name != "" { - body["name"] = name - } - if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(pc, raw, "data-config"); err == nil { - body["data_config"] = parsed - } - } + body, _ := buildDashboardBlockBody(pc, runtime, false, false) params := map[string]interface{}{} if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { params["user_id_type"] = userIDType @@ -274,19 +321,9 @@ func executeDashboardBlockGetData(runtime *common.RuntimeContext) error { // executeDashboardBlockCreate creates a new dashboard block. func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) - body := map[string]interface{}{} - if name := strings.TrimSpace(runtime.Str("name")); name != "" { - body["name"] = name - } - if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { - body["type"] = blockType - } - if raw := runtime.Str("data-config"); raw != "" { - parsed, err := parseJSONObject(pc, raw, "data-config") - if err != nil { - return err - } - body["data_config"] = parsed + body, err := buildDashboardBlockBody(pc, runtime, true, true) + if err != nil { + return err } params := map[string]interface{}{} @@ -305,16 +342,9 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { // executeDashboardBlockUpdate updates an existing dashboard block. func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) - body := map[string]interface{}{} - if name := strings.TrimSpace(runtime.Str("name")); name != "" { - body["name"] = name - } - if raw := runtime.Str("data-config"); raw != "" { - parsed, err := parseJSONObject(pc, raw, "data-config") - if err != nil { - return err - } - body["data_config"] = parsed + body, err := buildDashboardBlockBody(pc, runtime, false, true) + if err != nil { + return err } params := map[string]interface{}{} if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go index e95f775609..c9b3f7ed03 100644 --- a/shortcuts/base/helpers.go +++ b/shortcuts/base/helpers.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net/http" "net/url" "sort" @@ -528,6 +529,34 @@ func toInt(v interface{}) int { } } +// toIntStrict converts v to an int only when it represents an exact integer. +// Unlike toInt it rejects fractional numbers (e.g. 2.5), booleans and strings, +// so precision-like fields keep strict integer semantics regardless of whether +// the JSON was decoded with UseNumber (json.Number) or the default float64. +func toIntStrict(v interface{}) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int64: + return int(n), true + case json.Number: + // Int64 fails on a fractional literal such as "2.5"; that is the + // strict behaviour we want for precision. + i, err := n.Int64() + if err != nil { + return 0, false + } + return int(i), true + case float64: + if math.IsNaN(n) || math.IsInf(n, 0) || n != math.Trunc(n) { + return 0, false + } + return int(n), true + default: + return 0, false + } +} + func toStringSlice(v interface{}) []string { arr, ok := v.([]interface{}) if !ok { @@ -1011,6 +1040,17 @@ func sleepBetweenBatches(index int, total int) { // ── Dashboard Block data_config normalization & validation ─────────── +// validNumberFormatNames enumerates the statistics number_format.formatName +// values accepted by the dashboard block create/update commands. They map 1:1 +// to the backend snapshot formatInfo.formatName enum. +var validNumberFormatNames = map[string]bool{ + "digital": true, + "digital_without_separator": true, + "percentage_rounded": true, + "cyn_rounded": true, + "dollar_rounded": true, +} + // normalizeDataConfig normalizes data_config fields for dashboard blocks. // It converts series[].rollup to uppercase and group_by[].sort fields to lowercase. func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} { @@ -1187,9 +1227,42 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str } } } + // number_format 仅对指标卡(statistics)生效,其它类型静默忽略、不校验。 + if strings.ToLower(blockType) == "statistics" { + errs = append(errs, validateNumberFormat(cfg["number_format"])...) + } return errs } +// validateNumberFormat performs light validation of the statistics +// number_format object: formatName must be one of the 5 enums and precision +// must be an integer in [0, 9]. Both sub-fields are optional; a nil raw value +// (field absent) yields no error. precision uses strict integer semantics so a +// fractional literal such as 2.5 is rejected instead of being coerced. +func validateNumberFormat(raw interface{}) []string { + if raw == nil { + return nil + } + var problems []string + nf, ok := raw.(map[string]interface{}) + if !ok { + return []string{"number_format 必须是对象,例如 {\"formatName\":\"digital\",\"precision\":2}"} + } + if fnRaw, has := nf["formatName"]; has { + fn, isString := fnRaw.(string) + if !isString || !validNumberFormatNames[strings.TrimSpace(fn)] { + problems = append(problems, "number_format.formatName 仅支持 digital|digital_without_separator|percentage_rounded|cyn_rounded|dollar_rounded") + } + } + if pRaw, has := nf["precision"]; has { + p, ok := toIntStrict(pRaw) + if !ok || p < 0 || p > 9 { + problems = append(problems, "number_format.precision 必须是 0 到 9 的整数") + } + } + return problems +} + func formatDataConfigErrors(problems []string) error { if len(problems) == 0 { return nil diff --git a/skills/lark-base/references/dashboard-block-data-config.md b/skills/lark-base/references/dashboard-block-data-config.md index 3eaf3ffab3..e0bb19b7cc 100644 --- a/skills/lark-base/references/dashboard-block-data-config.md +++ b/skills/lark-base/references/dashboard-block-data-config.md @@ -340,6 +340,39 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty } ``` +### statistics 指标卡数值格式 number_format(可选) + +仅 `type: statistics` 的指标卡支持在 `data_config` 里加可选 `number_format`,控制数值展示精度与格式;不传时后端回退默认千分位整数(`digital`)。其它组件类型携带 `number_format` 会被静默忽略、不生效、不报错。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `number_format.formatName` | string | 否 | 数值格式名,取值见下表 | +| `number_format.precision` | integer | 否 | 小数位数,取值 `0` 到 `9` 的整数(本地严格校验,`2.5` 之类非整数会被拒) | + +`formatName` 枚举与含义: + +| formatName | 含义 | 示例(precision=2) | +|------------|------|--------------------| +| `digital` | 千分位数字(默认) | `1,234.56` | +| `digital_without_separator` | 无千分位数字 | `1234.56` | +| `percentage_rounded` | 百分比 | `1,234.56%` | +| `cyn_rounded` | 人民币金额 | `¥1,234.56` | +| `dollar_rounded` | 美元金额 | `$1,234.56` | + +> 本地会校验 `formatName` 属于上述 5 个枚举、`precision` 为 0..9 整数;`--no-validate` 跳过本地校验,由后端裁决。 + +指标卡(金额,保留 2 位小数): + +```json +{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"number_format":{"formatName":"dollar_rounded","precision":2}} +``` + +更新时 `number_format` 支持子字段合并:只传 `precision` 会保留原有 `formatName`,例如把已有指标卡改成 0 位小数: + +```json +{"number_format":{"precision":0}} +``` + 文本组件(Markdown 富文本): ```json diff --git a/skills/lark-base/references/lark-base-dashboard.md b/skills/lark-base/references/lark-base-dashboard.md index 7786e7ed1d..3cacb6290a 100644 --- a/skills/lark-base/references/lark-base-dashboard.md +++ b/skills/lark-base/references/lark-base-dashboard.md @@ -17,10 +17,31 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成** | 创建/删除/改名称 | `+dashboard-create/delete/update` | 本页下方「仪表盘管理」 | | 在仪表盘里添加组件 | `+dashboard-block-create` | 先定位 dashboard、表和字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` | | 修改组件 | `+dashboard-block-update` | 先读 block 现状,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 决定替换哪些顶层 key | +| 创建/更新时指定组件精确位置大小 | `+dashboard-block-create/update --position` | 本页下方「精确布局 --position vs +dashboard-arrange」 | | 查看仪表盘有哪些组件 | `+dashboard-get` 或 `+dashboard-block-list` | 本页下方「查看仪表盘」 | | 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` | | 智能重排组件布局 | `+dashboard-arrange` | 用户明确要求重排,或本次会话新建仪表盘的收尾整理;无法指定精确位置 | +## 精确布局 --position vs +dashboard-arrange + +创建/更新组件时可选 `--position`,用一个 12 列栅格坐标对象精确指定组件落点与大小: + +```bash +lark-cli base +dashboard-block-create --base-token xxx --dashboard-id blk_xxx --name "总销售额" --type statistics --data-config '{"table_name":"订单表","count_all":true}' --position '{"x":0,"y":0,"w":6,"h":4}' +``` + +`--position` 字段:`x`/`y` 为左上角栅格坐标(>=0),`w` 为宽度(1..12,且 `x+w<=12`),`h` 为高度(>=1)。JSON key 固定为 `x`/`y`/`w`/`h`,与 `name`/`type`/`data_config` 平级挂在请求体顶层。 + +> [!IMPORTANT] +> - `--position` 只做 JSON 解析,**不做坐标取值校验**:越界、负值、重叠都原样透传,服务端也**不做碰撞检测**。需要不重叠布局时由调用方自行规划坐标。 +> - 不传 `--position`:create 时服务端自动装箱(找空位),update 时保持当前布局不变。 +> - `--position` 与 `+dashboard-arrange` 用途不同,别混用: + +| 能力 | 时机 | 效果 | 适用 | +|------|------|------|------| +| `--position`(create/update) | 建/改组件的同时 | 精确指定单个组件的 x/y/w/h,适合**复刻已有看板布局**或按业务排布 | 需要可控、可复现的坐标 | +| `+dashboard-arrange` | 组件建好之后 | 服务端一键**智能重排**整个仪表盘,无法指定精确位置 | 只想一键美化、不在意具体落点 | + ## 典型场景工作流 ### 场景 1:从 0 到 1 创建仪表盘 diff --git a/tests/cli_e2e/base/base_dashboard_block_layout_precision_dryrun_test.go b/tests/cli_e2e/base/base_dashboard_block_layout_precision_dryrun_test.go new file mode 100644 index 0000000000..34a98fe2ae --- /dev/null +++ b/tests/cli_e2e/base/base_dashboard_block_layout_precision_dryrun_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBaseDashboardBlockCreateDryRun_PositionAndNumberFormat proves the +// create command dry-run surfaces the top-level position sibling and the +// statistics data_config.number_format in the request body preview. +func TestBaseDashboardBlockCreateDryRun_PositionAndNumberFormat(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+dashboard-block-create", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--name", "Revenue", + "--type", "statistics", + "--data-config", `{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}],"number_format":{"formatName":"dollar_rounded","precision":2}}`, + "--position", `{"x":0,"y":0,"w":6,"h":4}`, + "--dry-run", + }, + BinaryPath: "../../../lark-cli", + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") + assert.Contains(t, output, `"method": "POST"`) + // top-level position sibling of name/type/data_config + assert.Contains(t, output, `"position"`) + assert.Contains(t, output, `"w": 6`) + // number_format nested inside data_config + assert.Contains(t, output, `"number_format"`) + assert.Contains(t, output, `"dollar_rounded"`) +} + +// TestBaseDashboardBlockUpdateDryRun_Position proves the update command +// dry-run surfaces the top-level position sibling in the request body preview. +func TestBaseDashboardBlockUpdateDryRun_Position(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+dashboard-block-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--block-id", "blk_a", + "--position", `{"x":6,"y":0,"w":6,"h":4}`, + "--dry-run", + }, + BinaryPath: "../../../lark-cli", + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") + assert.Contains(t, output, `"method": "PATCH"`) + assert.Contains(t, output, `"position"`) + assert.Contains(t, output, `"x": 6`) +} + +// TestBaseDashboardBlockCreateDryRun_InvalidNumberFormat proves the statistics +// number_format validation rejects an out-of-enum formatName before any request. +func TestBaseDashboardBlockCreateDryRun_InvalidNumberFormat(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+dashboard-block-create", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--name", "Revenue", + "--type", "statistics", + "--data-config", `{"table_name":"Orders","count_all":true,"number_format":{"formatName":"bogus","precision":2}}`, + "--dry-run", + }, + BinaryPath: "../../../lark-cli", + DefaultAs: "bot", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode) + assert.Contains(t, result.Stderr, "formatName") +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index 75ca1a130d..5e071b8181 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -2,12 +2,13 @@ ## Metrics - Denominator: 78 leaf commands -- Covered: 22 -- Coverage: 28.2% +- Covered: 24 +- Coverage: 30.8% ## Summary - TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`. - TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data. +- TestBaseDashboardBlockCreateDryRun_PositionAndNumberFormat / TestBaseDashboardBlockUpdateDryRun_Position: prove `+dashboard-block-create` / `+dashboard-block-update` dry-run request shape carries the optional top-level `position` sibling and statistics `data_config.number_format`; TestBaseDashboardBlockCreateDryRun_InvalidNumberFormat proves the number_format enum validation rejects a bad formatName before any request. - TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path. - TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape. - TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base. @@ -31,11 +32,11 @@ | ✓ | base +base-block-move | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/move root,move after | `--base-token`; `--block-id`; optional `--parent-id`; `--after-id`; dry-run only | request shape only | | ✓ | base +base-block-rename | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/rename | `--base-token`; `--block-id`; `--name`; dry-run only | request shape only | | ✕ | base +dashboard-arrange | shortcut | | none | dashboard workflows not covered | -| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered | +| ✓ | base +dashboard-block-create | shortcut | base_dashboard_block_layout_precision_dryrun_test.go::TestBaseDashboardBlockCreateDryRun_PositionAndNumberFormat | `--position` top-level; `--data-config.number_format`; dry-run only | request shape: position + statistics number_format passthrough | | ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered | -| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered | +| ✓ | base +dashboard-block-update | shortcut | base_dashboard_block_layout_precision_dryrun_test.go::TestBaseDashboardBlockUpdateDryRun_Position | `--position` top-level; dry-run only | request shape: position passthrough | | ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-delete | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-get | shortcut | | none | dashboard workflows not covered | From bb7d8fbcbec56f54c76978083f7261ee06d9701e Mon Sep 17 00:00:00 2001 From: "wanglei.75" Date: Thu, 30 Jul 2026 18:12:39 +0800 Subject: [PATCH 2/2] fix(base): validate number_format on update path and add symmetry tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard-block-update command parsed data_config but never ran the statistics number_format check, so an illegal formatName/precision slipped through locally while create rejected it — violating the SSOT + backend-design §4.5 promise of CLI-side interception on BOTH paths. Update has no --type flag (block type is immutable) and intentionally skips strong type validation, so it now reuses the shared validateNumberFormat sub-validator that validateBlockDataConfig delegates to, keeping create/update symmetric without demanding table_name/series on a number_format-only update. Also: add tests for the --no-validate bypass on create+update, a combined update carrying position + number_format + name, and extend the DryRun/Execute body isomorphism assertion to the update path. Clarify the --position flag Desc that coordinate bounds are advisory (not validated locally or server-side) and sync the lark-base SKILL.md routing table for --position / number_format. Co-authored-by: TRAE CLI --- .../base_dashboard_layout_precision_test.go | 131 ++++++++++++++++++ shortcuts/base/base_dryrun_ops_test.go | 13 ++ shortcuts/base/dashboard_block_create.go | 2 +- shortcuts/base/dashboard_block_update.go | 11 +- skills/lark-base/SKILL.md | 2 +- 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/shortcuts/base/base_dashboard_layout_precision_test.go b/shortcuts/base/base_dashboard_layout_precision_test.go index 405e4b29ea..796cd5793f 100644 --- a/shortcuts/base/base_dashboard_layout_precision_test.go +++ b/shortcuts/base/base_dashboard_layout_precision_test.go @@ -269,6 +269,137 @@ func TestBaseDashboardBlockCreate_NumberFormatInvalidRejected(t *testing.T) { } } +// TestBaseDashboardBlockUpdate_NumberFormatInvalidRejected proves the update +// command intercepts an illegal statistics number_format locally, symmetric with +// create (FIX-1 / backend-design §4.5). Update has no --type flag, so this must +// still fire without demanding table_name/series (which update intentionally +// leaves to the server). +func TestBaseDashboardBlockUpdate_NumberFormatInvalidRejected(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", + "--data-config", `{"number_format":{"formatName":"bogus","precision":2}}`, + } + err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout) + if err == nil { + t.Fatalf("expected validation error for bad formatName on update") + } + if !strings.Contains(err.Error(), "formatName") || !strings.Contains(err.Error(), "data_config 校验失败") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestBaseDashboardBlockUpdate_NumberFormatOnlyAllowed proves that a +// number_format-only data_config (no table_name/series) passes update +// validation — the update path must not run create's strong type checks. +func TestBaseDashboardBlockUpdate_NumberFormatOnlyAllowed(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_a"}}, + } + reg.Register(stub) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", + "--data-config", `{"number_format":{"precision":0}}`, + } + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("number_format-only update must pass validation, got err=%v", err) + } + body := decodeCapturedBody(t, stub.CapturedBody) + dc, _ := body["data_config"].(map[string]interface{}) + nf, ok := dc["number_format"].(map[string]interface{}) + if !ok || toInt(nf["precision"]) != 0 { + t.Fatalf("number_format not forwarded: body=%s", string(stub.CapturedBody)) + } +} + +// TestBaseDashboardBlockNoValidateBypass proves --no-validate lets an otherwise +// illegal statistics number_format pass through untouched on BOTH create and +// update paths (the light enum/precision check is skipped along with the rest of +// data_config validation). +func TestBaseDashboardBlockNoValidateBypass(t *testing.T) { + t.Run("create", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_new"}}, + } + reg.Register(stub) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "N", "--type", "statistics", + "--data-config", `{"table_name":"T","count_all":true,"number_format":{"formatName":"bogus","precision":42}}`, + "--no-validate", + } + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("--no-validate must bypass number_format validation, got err=%v", err) + } + body := decodeCapturedBody(t, stub.CapturedBody) + dc, _ := body["data_config"].(map[string]interface{}) + nf, ok := dc["number_format"].(map[string]interface{}) + if !ok || nf["formatName"] != "bogus" || toInt(nf["precision"]) != 42 { + t.Fatalf("illegal number_format must pass through verbatim: body=%s", string(stub.CapturedBody)) + } + }) + + t.Run("update", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_a"}}, + } + reg.Register(stub) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", + "--data-config", `{"number_format":{"formatName":"bogus","precision":42}}`, + "--no-validate", + } + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("--no-validate must bypass number_format validation on update, got err=%v", err) + } + body := decodeCapturedBody(t, stub.CapturedBody) + dc, _ := body["data_config"].(map[string]interface{}) + nf, ok := dc["number_format"].(map[string]interface{}) + if !ok || nf["formatName"] != "bogus" || toInt(nf["precision"]) != 42 { + t.Fatalf("illegal number_format must pass through verbatim: body=%s", string(stub.CapturedBody)) + } + }) +} + +// TestBaseDashboardBlockExecuteUpdate_PositionNumberFormatName proves a single +// update call carrying position + number_format + name forwards all three into +// the request body together. +func TestBaseDashboardBlockExecuteUpdate_PositionNumberFormatName(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_a"}}, + } + reg.Register(stub) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", + "--name", "Total Sales", + "--data-config", `{"number_format":{"formatName":"dollar_rounded","precision":2}}`, + "--position", `{"x":6,"y":0,"w":6,"h":4}`, + } + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + body := decodeCapturedBody(t, stub.CapturedBody) + if body["name"] != "Total Sales" { + t.Fatalf("name missing/wrong: body=%s", string(stub.CapturedBody)) + } + pos, ok := body["position"].(map[string]interface{}) + if !ok || toInt(pos["x"]) != 6 || toInt(pos["w"]) != 6 { + t.Fatalf("position not forwarded: %v", pos) + } + dc, _ := body["data_config"].(map[string]interface{}) + nf, ok := dc["number_format"].(map[string]interface{}) + if !ok || nf["formatName"] != "dollar_rounded" || toInt(nf["precision"]) != 2 { + t.Fatalf("number_format not forwarded: %v", nf) + } +} + func decodeCapturedBody(t *testing.T, raw []byte) map[string]interface{} { t.Helper() var body map[string]interface{} diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index ec4878395a..96e118c698 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -441,6 +441,19 @@ func TestDryRunDashboardBlockPositionAndNumberFormat(t *testing.T) { if !reflect.DeepEqual(dryBody, execBody) { t.Fatalf("dry-run and execute bodies diverge:\n dry=%v\nexec=%v", dryBody, execBody) } + + // Same isomorphism must hold on the update path (includeType=false). + dryUpdate, _ := buildDashboardBlockBody(pc, rt, false, false) + execUpdate, err := buildDashboardBlockBody(pc, rt, false, true) + if err != nil { + t.Fatalf("update execute body build err=%v", err) + } + if !reflect.DeepEqual(dryUpdate, execUpdate) { + t.Fatalf("update dry-run and execute bodies diverge:\n dry=%v\nexec=%v", dryUpdate, execUpdate) + } + if _, hasType := dryUpdate["type"]; hasType { + t.Fatalf("update body must not carry type: %v", dryUpdate) + } } func TestDryRunViewOps(t *testing.T) { diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index 6bad369703..a16dcfa247 100644 --- a/shortcuts/base/dashboard_block_create.go +++ b/shortcuts/base/dashboard_block_create.go @@ -26,7 +26,7 @@ var BaseDashboardBlockCreate = common.Shortcut{ {Name: "name", Desc: "block name", Required: true}, {Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true}, {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, - {Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; x/y>=0, 1<=w<=12 and x+w<=12, h>=1; omit for server auto-layout`}, + {Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; advisory bounds x/y>=0, 1<=w<=12 and x+w<=12, h>=1 (NOT validated locally or server-side, out-of-range passes through); omit for server auto-layout`}, {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"}, }, diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go index 65176a42fe..4e032e6ff6 100644 --- a/shortcuts/base/dashboard_block_update.go +++ b/shortcuts/base/dashboard_block_update.go @@ -25,7 +25,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{ blockIDFlag(true), {Name: "name", Desc: "new block name"}, {Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"}, - {Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; x/y>=0, 1<=w<=12 and x+w<=12, h>=1; omit to leave layout unchanged`}, + {Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; advisory bounds x/y>=0, 1<=w<=12 and x+w<=12, h>=1 (NOT validated locally or server-side, out-of-range passes through); omit to leave layout unchanged`}, {Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"}, {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"}, }, @@ -57,7 +57,14 @@ var BaseDashboardBlockUpdate = common.Shortcut{ return err } norm := normalizeDataConfig(cfg) - // update 时不做强类型校验(不传 type),让后端验证具体字段 + // update 时不做强类型校验(不传 type),让后端验证具体字段。 + // 但 number_format 必须与 create 对称地本地拦截(backend-design §4.5): + // update 无 type flag,无法调用 validateBlockDataConfig 的完整分支 + // (那会误报 table_name/series 缺失,破坏仅改 number_format 的用法), + // 因此直接复用其委托的 number_format 子校验,保持双路径一致。 + if problems := validateNumberFormat(norm["number_format"]); len(problems) > 0 { + return formatDataConfigErrors(problems) + } b, _ := json.Marshal(norm) _ = runtime.Cmd.Flags().Set("data-config", string(b)) return nil diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 19dcba9209..d4c37372a5 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -64,7 +64,7 @@ metadata: | 表单提交 | `+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);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.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` | +| 仪表盘与组件 | `+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);create/update 可选 `--position`(12 列栅格 `{x,y,w,h}` 精确布局,坐标仅本地解析不校验、越界不报错)与指标卡 `data_config.number_format`(`formatName`/`precision` 本地校验,create/update 对称拦截);读取图表计算结果用 `+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);系统角色不可删除;关闭高级权限会影响自定义角色 |