Files
larksuite__cli/shortcuts/base/block_data_config.go
wanghm-bytedance a257fcbaf9 feat(base): support ranking dashboard blocks (#2528)
* feat(base): support ranking dashboard blocks

* fix(base): align ranking validation with strict schema

* fix(base): scope ranking validation to dashboards
2026-08-31 15:33:13 +08:00

766 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"github.com/larksuite/cli/errs"
)
// ── Public block types ───────────────────────────────────────────────
// chartBlockTypes are the chart and statistics block types shared by dashboard
// blocks and BaseApp page blocks.
var chartBlockTypes = []string{
"column", "bar", "line", "pie", "ring", "scatter",
"funnel", "wordCloud", "area", "combo", "radar", "statistics",
}
// textBlockTypes are the text-ish block types. Dashboard blocks and BaseApp
// page blocks share the same spelling "text" and the same data_config shape
// ({"text": "..."}).
var textBlockTypes = []string{"text"}
var validNumberFormatNames = map[string]bool{
"digital": true,
"digital_without_separator": true,
"percentage_rounded": true,
"cyn_rounded": true,
"dollar_rounded": true,
}
func matchesBlockType(blockType string, candidates []string) bool {
trimmed := strings.ToLower(strings.TrimSpace(blockType))
for _, candidate := range candidates {
if trimmed == strings.ToLower(candidate) {
return true
}
}
return false
}
func isTextBlockType(blockType string) bool { return matchesBlockType(blockType, textBlockTypes) }
func isChartBlockType(blockType string) bool { return matchesBlockType(blockType, chartBlockTypes) }
// appBlockTypes are all block types accepted by the BaseApp page block
// commands, in the order they appear in the protocol design.
func appBlockTypes() []string {
types := make([]string, 0, len(chartBlockTypes)+2)
types = append(types, chartBlockTypes...)
types = append(types, "text")
types = append(types, "list")
return types
}
func isAppBlockType(blockType string) bool {
return isChartBlockType(blockType) || matchesBlockType(blockType, []string{"text", "list"})
}
// ── data_config normalization & validation ───────────────────────────
// normalizeDataConfig normalizes data_config fields for chart blocks.
// It converts series[].rollup to uppercase and group_by[].sort fields to lowercase.
func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
if cfg == nil {
return nil
}
out := cloneMap(cfg)
// series[].rollup → 大写
if arr, ok := out["series"].([]interface{}); ok {
for i, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
if r, ok := m["rollup"].(string); ok && r != "" {
m["rollup"] = strings.ToUpper(strings.TrimSpace(r))
}
arr[i] = m
}
}
out["series"] = arr
}
// group_by.sort 的 type/order → 小写
if gb, ok := out["group_by"].([]interface{}); ok {
for i, g := range gb {
if m, ok := g.(map[string]interface{}); ok {
if md, ok := m["mode"].(string); ok {
m["mode"] = strings.ToLower(strings.TrimSpace(md))
}
if sub, ok := m["sort"].(map[string]interface{}); ok {
sortType := ""
if t, ok := sub["type"].(string); ok {
sortType = strings.ToLower(strings.TrimSpace(t))
sub["type"] = sortType
}
// Only lowercase a string order; leave a present-but-non-string
// order untouched so validateBlockDataConfig can reject it
// instead of it being silently coerced below.
_, hasOrderKey := sub["order"]
orderStr, orderIsString := sub["order"].(string)
if orderIsString {
sub["order"] = strings.ToLower(strings.TrimSpace(orderStr))
}
// Default only when the order key is truly absent. A present
// key (even an illegal type/value) must survive to validation.
if !hasOrderKey && (sortType == "group" || sortType == "view") {
sub["order"] = "asc"
}
m["sort"] = sub
}
gb[i] = m
}
}
out["group_by"] = gb
}
return out
}
// normalizeDataConfigForCreate adds type-specific defaults only when the
// create request declares the block type.
func normalizeDataConfigForCreate(blockType string, cfg map[string]interface{}) map[string]interface{} {
out := normalizeDataConfig(cfg)
if !matchesBlockType(blockType, []string{"ranking"}) || out == nil {
return out
}
if _, ok := out["limit_size"]; !ok {
out["limit_size"] = float64(10)
}
if groups, ok := out["group_by"].([]interface{}); ok && len(groups) == 1 {
if group, ok := groups[0].(map[string]interface{}); ok {
if _, ok := group["sort"]; !ok {
group["sort"] = map[string]interface{}{"type": "value", "order": "desc"}
}
}
}
return out
}
// validateBlockDataConfig validates data_config based on block type.
// Text blocks only need a text field; everything else falls through to the
// dashboard chart rules. BaseApp list validation lives in
// app_list_block_data_config.go and never enters this dashboard path.
func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
blockType = strings.ToLower(strings.TrimSpace(blockType))
if _, hasNumberFormat := cfg["number_format"]; hasNumberFormat && blockType != "statistics" {
return []string{"number_format 仅支持 statistics 类型组件"}
}
switch {
case isTextBlockType(blockType):
return validateTextDataConfig(blockType, cfg)
case matchesBlockType(blockType, []string{"ranking"}):
return validateRankingDataConfig(cfg)
default:
problems := validateChartDataConfig(cfg)
if matchesBlockType(blockType, []string{"statistics"}) {
if rawNumberFormat, hasNumberFormat := cfg["number_format"]; hasNumberFormat {
problems = append(problems, validateNumberFormat(rawNumberFormat)...)
}
}
return problems
}
}
// validateTextDataConfig validates the text data_config shape.
func validateTextDataConfig(blockType string, cfg map[string]interface{}) []string {
var problems []string
if txt, _ := cfg["text"].(string); strings.TrimSpace(txt) == "" {
problems = append(problems, fmt.Sprintf("%s 类型组件缺少必填字段 text", strings.TrimSpace(blockType)))
}
return problems
}
// validateChartDataConfig validates the chart/statistics data_config shape.
func validateChartDataConfig(cfg map[string]interface{}) []string {
var errs []string
// 图表类型通用校验
// table_name 必填
if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" {
errs = append(errs, "缺少必填字段 table_name")
}
// series 与 count_all 互斥且必有其一
_, hasSeries := cfg["series"]
_, hasCountAll := cfg["count_all"]
if !(hasSeries || hasCountAll) {
errs = append(errs, "series 与 count_all 二选一,至少提供其一")
}
if hasSeries && hasCountAll {
errs = append(errs, "series 与 count_all 互斥,不可同时存在")
}
// series 校验
if hasSeries {
arr, ok := cfg["series"].([]interface{})
if !ok || len(arr) == 0 {
errs = append(errs, "series 必须是非空数组")
} else {
// rollup 支持:SUM / MAX / MIN / AVERAGE(不支持 COUNTA;计数请使用 count_all)
allowed := map[string]bool{"SUM": true, "MAX": true, "MIN": true, "AVERAGE": true}
for i, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
errs = append(errs, fmt.Sprintf("series[%d] 必须是对象", i))
continue
}
fn, _ := m["field_name"].(string)
if strings.TrimSpace(fn) == "" {
errs = append(errs, fmt.Sprintf("series[%d].field_name 不能为空", i))
}
r, _ := m["rollup"].(string)
r = strings.ToUpper(strings.TrimSpace(r))
if !allowed[r] {
errs = append(errs, fmt.Sprintf("series[%d].rollup 不在允许枚举内: %s", i, r))
}
}
}
}
// group_by 最多 2 个,字段名必填,sort 合法
if gb, ok := cfg["group_by"].([]interface{}); ok {
if len(gb) > 2 {
errs = append(errs, "group_by 最多支持 2 个维度")
}
for i, g := range gb {
m, ok := g.(map[string]interface{})
if !ok {
errs = append(errs, fmt.Sprintf("group_by[%d] 必须是对象", i))
continue
}
fn, _ := m["field_name"].(string)
if strings.TrimSpace(fn) == "" {
errs = append(errs, fmt.Sprintf("group_by[%d].field_name 不能为空", i))
}
if sub, ok := m["sort"].(map[string]interface{}); ok {
t, _ := sub["type"].(string)
t = strings.ToLower(strings.TrimSpace(t))
if t != "group" && t != "value" && t != "view" {
errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
}
orderRaw, hasOrder := sub["order"]
o, orderIsString := orderRaw.(string)
o = strings.ToLower(strings.TrimSpace(o))
switch {
case !hasOrder:
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 缺失;sort 存在时必须设置 order 为 asc 或 desc,例如 \"sort\":{\"type\":\"group\",\"order\":\"asc\"}", i))
case !orderIsString || (o != "asc" && o != "desc"):
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
}
}
}
}
// filter 基本结构
errs = append(errs, validateBlockFilter(cfg, "filter", false)...)
return errs
}
func validateNumberFormat(raw interface{}) []string {
if raw == nil {
return []string{"number_format 必须是对象,例如 {\"formatName\":\"digital\",\"precision\":2}"}
}
nf, ok := raw.(map[string]interface{})
if !ok {
return []string{"number_format 必须是对象,例如 {\"formatName\":\"digital\",\"precision\":2}"}
}
var problems []string
if fnRaw, has := nf["formatName"]; has {
fn, isString := fnRaw.(string)
if !isString || !validNumberFormatNames[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 validateRankingDataConfig(cfg map[string]interface{}) []string {
var problems []string
if tableName, _ := cfg["table_name"].(string); strings.TrimSpace(tableName) == "" {
problems = append(problems, "ranking 缺少必填字段 table_name")
}
for _, field := range unexpectedObjectFields(cfg, "table_name", "series", "count_all", "group_by", "filter", "limit_size") {
switch field {
case "sort":
problems = append(problems, "ranking 不支持顶层 sort;请使用 group_by[0].sort")
case "ranking", "is_need_avatar", "isNeedAvatar":
problems = append(problems, fmt.Sprintf("ranking 不支持公开字段 %s", field))
default:
problems = append(problems, fmt.Sprintf("ranking 不支持字段 %s", field))
}
}
series, hasSeries := cfg["series"]
countAll, hasCountAll := cfg["count_all"]
if hasSeries == hasCountAll {
problems = append(problems, "ranking 的 series 与 count_all:true 必须二选一")
}
if hasCountAll {
if value, ok := countAll.(bool); !ok || !value {
problems = append(problems, "ranking.count_all 只能为 true")
}
}
if hasSeries {
items, ok := series.([]interface{})
if !ok || len(items) != 1 {
problems = append(problems, "ranking.series 必须严格包含 1 个指标")
} else if item, ok := items[0].(map[string]interface{}); !ok {
problems = append(problems, "ranking.series[0] 必须是对象")
} else {
for _, field := range unexpectedObjectFields(item, "field_name", "rollup") {
problems = append(problems, fmt.Sprintf("ranking.series[0] 不支持字段 %s", field))
}
if fieldName, _ := item["field_name"].(string); strings.TrimSpace(fieldName) == "" {
problems = append(problems, "ranking.series[0].field_name 不能为空")
}
rollup, _ := item["rollup"].(string)
allowed := map[string]bool{"SUM": true, "MAX": true, "MIN": true, "AVERAGE": true}
if !allowed[strings.ToUpper(strings.TrimSpace(rollup))] {
problems = append(problems, "ranking.series[0].rollup 仅支持 SUM|MAX|MIN|AVERAGE")
}
}
}
groups, ok := cfg["group_by"].([]interface{})
if !ok || len(groups) != 1 {
problems = append(problems, "ranking.group_by 必须严格包含 1 个分组")
} else if group, ok := groups[0].(map[string]interface{}); !ok {
problems = append(problems, "ranking.group_by[0] 必须是对象")
} else {
for _, field := range unexpectedObjectFields(group, "field_name", "mode", "sort") {
problems = append(problems, fmt.Sprintf("ranking.group_by[0] 不支持字段 %s", field))
}
if fieldName, _ := group["field_name"].(string); strings.TrimSpace(fieldName) == "" {
problems = append(problems, "ranking.group_by[0].field_name 不能为空")
}
if mode, exists := group["mode"]; exists {
modeValue, ok := mode.(string)
if !ok || (modeValue != "integrated" && modeValue != "enumerated") {
problems = append(problems, "ranking.group_by[0].mode 仅支持 integrated|enumerated")
}
}
sortConfig, ok := group["sort"].(map[string]interface{})
if !ok {
problems = append(problems, "ranking.group_by[0].sort 必须是对象")
} else {
for _, field := range unexpectedObjectFields(sortConfig, "type", "order") {
problems = append(problems, fmt.Sprintf("ranking.group_by[0].sort 不支持字段 %s", field))
}
if sortType, _ := sortConfig["type"].(string); sortType != "value" {
problems = append(problems, "ranking.group_by[0].sort.type 只能为 value")
}
order, ok := sortConfig["order"].(string)
if !ok || (order != "asc" && order != "desc") {
problems = append(problems, "ranking.group_by[0].sort.order 仅支持 asc|desc")
}
}
}
limit, ok := cfg["limit_size"].(float64)
if !ok || limit != math.Trunc(limit) || limit < 1 || limit > 500 {
problems = append(problems, "ranking.limit_size 必须是 1..500 的整数")
}
if filter, ok := cfg["filter"].(map[string]interface{}); ok {
for _, field := range unexpectedObjectFields(filter, "conjunction", "conditions") {
problems = append(problems, fmt.Sprintf("ranking.filter 不支持字段 %s", field))
}
if conditions, ok := filter["conditions"].([]interface{}); ok {
for i, rawCondition := range conditions {
if condition, ok := rawCondition.(map[string]interface{}); ok {
for _, field := range unexpectedObjectFields(condition, "field_name", "operator", "value") {
problems = append(problems, fmt.Sprintf("ranking.filter.conditions[%d] 不支持字段 %s", i, field))
}
}
}
}
}
problems = append(problems, validateProtocolFilter(cfg, "filter")...)
return problems
}
func unexpectedObjectFields(object map[string]interface{}, allowedFields ...string) []string {
allowed := make(map[string]struct{}, len(allowedFields))
for _, field := range allowedFields {
allowed[field] = struct{}{}
}
unexpected := make([]string, 0)
for field := range object {
if _, ok := allowed[field]; !ok {
unexpected = append(unexpected, field)
}
}
sort.Strings(unexpected)
return unexpected
}
// ── BaseApp chart data_config (multi-datasource) ─────────────────────
//
// BaseApp page charts differ from dashboard charts by supporting multiple
// data sources: base_token is a single top-level value shared by every
// source, while table_name/series/count_all/group_by/filter move into each data_sources[]
// element. The per-source value semantics are identical to the dashboard
// chart rules, so each element reuses normalizeDataConfig /
// validateChartDataConfig; the wrapper only adds the top-level structure.
// normalizeAppChartDataConfig normalizes each data_sources[] element with the
// shared chart normalization (series[].rollup upper-case, group_by[].sort
// lower-case). Top-level base_token/data_source_mode/sort pass through. It is
// also safe for partial updates and non-chart configs: when data_sources is
// absent the config is returned unchanged.
func normalizeAppChartDataConfig(cfg map[string]interface{}) map[string]interface{} {
if cfg == nil {
return nil
}
out := cloneMap(cfg)
if mode, ok := out["data_source_mode"].(string); ok {
out["data_source_mode"] = strings.ToLower(strings.TrimSpace(mode))
}
if sortConfig, ok := out["sort"].(map[string]interface{}); ok {
if sortType, ok := sortConfig["type"].(string); ok {
sortConfig["type"] = strings.ToLower(strings.TrimSpace(sortType))
}
if order, ok := sortConfig["order"].(string); ok {
sortConfig["order"] = strings.ToLower(strings.TrimSpace(order))
}
out["sort"] = sortConfig
}
if sources, ok := out["data_sources"].([]interface{}); ok {
normalized := make([]interface{}, len(sources))
for i, s := range sources {
if m, ok := s.(map[string]interface{}); ok {
normalized[i] = normalizeDataConfig(cloneMap(m))
} else {
normalized[i] = s
}
}
out["data_sources"] = normalized
}
return out
}
// validateAppChartDataConfig validates the multi-datasource ChartDataConfig
// shape used by BaseApp page charts.
func validateAppChartDataConfig(blockType string, cfg map[string]interface{}) []string {
var problems []string
isStatistics := matchesBlockType(blockType, []string{"statistics"})
allowed := map[string]bool{
"base_token": true, "data_sources": true, "data_source_mode": true, "sort": true,
}
for key := range cfg {
if !allowed[key] {
problems = append(problems, fmt.Sprintf("图表 data_config 不支持字段 %s", key))
}
}
// 顶层 base_token 必填;App 命令不带 --base-token,所有数据源共用它。
if bt, _ := cfg["base_token"].(string); strings.TrimSpace(bt) == "" {
problems = append(problems, "缺少必填字段 base_token;App 图表所有数据源共用顶层同一个 base_token")
}
// data_source_mode 可选枚举:aggregate(默认)/ compare。
if mode, has := cfg["data_source_mode"]; has {
s, _ := mode.(string)
switch strings.ToLower(strings.TrimSpace(s)) {
case "aggregate", "compare":
default:
problems = append(problems, "data_source_mode 仅支持 aggregate|compare")
}
}
// 顶层 sort:statistics 不允许;其余校验 type/order。
if sortRaw, has := cfg["sort"]; has {
switch {
case isStatistics:
problems = append(problems, "statistics 不允许配置顶层 sort")
default:
if sub, ok := sortRaw.(map[string]interface{}); ok {
t, _ := sub["type"].(string)
switch strings.ToLower(strings.TrimSpace(t)) {
case "group", "value", "record":
default:
problems = append(problems, "sort.type 仅支持 group|value|record")
}
if o, hasOrder := sub["order"]; hasOrder {
os, isString := o.(string)
os = strings.ToLower(strings.TrimSpace(os))
if !isString || (os != "asc" && os != "desc") {
problems = append(problems, "sort.order 仅支持 asc|desc")
}
}
} else {
problems = append(problems, "sort 必须是对象")
}
}
}
// data_sources 必填、非空数组;每项复用图表通用校验。
rawSources, has := cfg["data_sources"]
if !has {
return append(problems, "缺少必填字段 data_sources;至少提供一个数据源")
}
sources, ok := rawSources.([]interface{})
if !ok || len(sources) == 0 {
return append(problems, "data_sources 必须是至少包含一项的数组")
}
for i, s := range sources {
m, ok := s.(map[string]interface{})
if !ok {
problems = append(problems, fmt.Sprintf("data_sources[%d] 必须是对象", i))
continue
}
for _, p := range validateAppChartDataSourceConfig(m) {
problems = append(problems, fmt.Sprintf("data_sources[%d]: %s", i, p))
}
// statistics 不配置分组。
if isStatistics {
if gb, ok := m["group_by"].([]interface{}); ok && len(gb) > 0 {
problems = append(problems, fmt.Sprintf("data_sources[%d]: statistics 不允许配置 group_by", i))
}
}
}
return problems
}
// validateAppBlockDataConfig routes BaseApp block validation: text blocks use
// the shared text rule; chart/statistics blocks use the multi-datasource chart
// rule. List blocks validate in app_list_block_data_config.go and never reach
// here.
func validateAppBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
if isTextBlockType(blockType) {
var problems []string
for key := range cfg {
if key != "text" {
problems = append(problems, fmt.Sprintf("富文本 data_config 不支持字段 %s", key))
}
}
if raw, exists := cfg["text"]; exists {
if _, ok := raw.(string); !ok {
problems = append(problems, "text 必须是字符串")
}
}
return problems
}
return validateAppChartDataConfig(blockType, cfg)
}
func validateAppChartDataSourceConfig(cfg map[string]interface{}) []string {
var problems []string
allowed := map[string]bool{
"table_name": true, "series": true, "count_all": true, "group_by": true, "filter": true,
}
for key := range cfg {
if !allowed[key] {
problems = append(problems, fmt.Sprintf("不支持字段 %s", key))
}
}
if tableName, _ := cfg["table_name"].(string); strings.TrimSpace(tableName) == "" {
problems = append(problems, "缺少必填字段 table_name")
}
seriesRaw, hasSeries := cfg["series"]
countRaw, hasCountAll := cfg["count_all"]
if hasSeries == hasCountAll {
problems = append(problems, "series 与 count_all 必须二选一")
}
if hasSeries {
series, ok := seriesRaw.([]interface{})
if !ok || len(series) < 1 || len(series) > 20 {
problems = append(problems, "series 必须是包含 1~20 项的数组")
} else {
allowedRollups := map[string]bool{"SUM": true, "MAX": true, "MIN": true, "AVERAGE": true}
for i, raw := range series {
item, ok := raw.(map[string]interface{})
if !ok {
problems = append(problems, fmt.Sprintf("series[%d] 必须是对象", i))
continue
}
if fieldName, _ := item["field_name"].(string); strings.TrimSpace(fieldName) == "" {
problems = append(problems, fmt.Sprintf("series[%d].field_name 必填", i))
}
rollup, _ := item["rollup"].(string)
if !allowedRollups[rollup] {
problems = append(problems, fmt.Sprintf("series[%d].rollup 仅支持 SUM|MAX|MIN|AVERAGE", i))
}
}
}
}
if hasCountAll {
if count, ok := countRaw.(bool); !ok || !count {
problems = append(problems, "count_all 只允许传 true")
}
}
if groupsRaw, exists := cfg["group_by"]; exists {
groups, ok := groupsRaw.([]interface{})
if !ok || len(groups) > 2 {
problems = append(problems, "group_by 必须是最多包含 2 项的数组")
} else {
for i, raw := range groups {
group, ok := raw.(map[string]interface{})
if !ok {
problems = append(problems, fmt.Sprintf("group_by[%d] 必须是对象", i))
continue
}
if fieldName, _ := group["field_name"].(string); strings.TrimSpace(fieldName) == "" {
problems = append(problems, fmt.Sprintf("group_by[%d].field_name 必填", i))
}
if modeRaw, exists := group["mode"]; exists {
mode, _ := modeRaw.(string)
if mode != "enumerated" && mode != "integrated" {
problems = append(problems, fmt.Sprintf("group_by[%d].mode 仅支持 enumerated|integrated", i))
}
}
if sortRaw, exists := group["sort"]; exists {
sortConfig, ok := sortRaw.(map[string]interface{})
if !ok {
problems = append(problems, fmt.Sprintf("group_by[%d].sort 必须是对象", i))
continue
}
sortType, _ := sortConfig["type"].(string)
if sortType != "group" && sortType != "value" && sortType != "view" {
problems = append(problems, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
}
if orderRaw, exists := sortConfig["order"]; exists {
order, _ := orderRaw.(string)
if order != "asc" && order != "desc" {
problems = append(problems, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
}
}
}
}
}
}
problems = append(problems, validateProtocolFilter(cfg, "filter")...)
return problems
}
func validateProtocolFilter(cfg map[string]interface{}, key string) []string {
raw, exists := cfg[key]
if !exists {
return nil
}
filter, ok := raw.(map[string]interface{})
if !ok {
return []string{key + " 必须是对象"}
}
var problems []string
conjunction, _ := filter["conjunction"].(string)
if conjunction != "and" && conjunction != "or" {
problems = append(problems, key+".conjunction 必填且仅支持 and|or")
}
conditions, ok := filter["conditions"].([]interface{})
if !ok || len(conditions) < 1 || len(conditions) > 50 {
return append(problems, key+".conditions 必须是包含 1~50 项的数组")
}
allowedOperators := map[string]bool{
"is": true, "isNot": true, "contains": true, "doesNotContain": true,
"isEmpty": true, "isNotEmpty": true, "isGreater": true, "isGreaterEqual": true,
"isLess": true, "isLessEqual": true,
}
for i, rawCondition := range conditions {
condition, ok := rawCondition.(map[string]interface{})
if !ok {
problems = append(problems, fmt.Sprintf("%s.conditions[%d] 必须是对象", key, i))
continue
}
if fieldName, _ := condition["field_name"].(string); strings.TrimSpace(fieldName) == "" {
problems = append(problems, fmt.Sprintf("%s.conditions[%d].field_name 必填", key, i))
}
operator, _ := condition["operator"].(string)
if !allowedOperators[operator] {
problems = append(problems, fmt.Sprintf("%s.conditions[%d].operator 不支持: %s", key, i, operator))
}
value, hasValue := condition["value"]
if operator != "isEmpty" && operator != "isNotEmpty" && !hasValue {
problems = append(problems, fmt.Sprintf("%s.conditions[%d].value 缺失", key, i))
}
if hasValue && !validProtocolFilterValue(value) {
problems = append(problems, fmt.Sprintf("%s.conditions[%d].value 必须是字符串、数字、布尔值或最多 200 项的对应数组", key, i))
}
}
return problems
}
func validProtocolFilterValue(value interface{}) bool {
switch typed := value.(type) {
case string, float64, bool, json.Number:
return true
case []interface{}:
if len(typed) > 200 {
return false
}
for _, item := range typed {
switch item.(type) {
case string, float64, bool, json.Number:
default:
return false
}
}
return true
default:
return false
}
}
// validateBlockFilter validates the filter object shared by chart and list
// data_config. key is the config key holding the filter ("filter").
// allowFieldID lets list blocks reference a field by ID; chart blocks keep the
// dashboard rule of field_name only.
func validateBlockFilter(cfg map[string]interface{}, key string, allowFieldID bool) []string {
f, ok := cfg[key].(map[string]interface{})
if !ok {
return nil
}
var problems []string
conj := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", f["conjunction"])))
if conj == "" {
conj = "and"
}
if conj != "and" && conj != "or" {
problems = append(problems, key+".conjunction 仅支持 and|or")
}
conds, ok := f["conditions"].([]interface{})
if !ok {
return problems
}
allowedOps := map[string]bool{"is": true, "isnot": true, "contains": true, "doesnotcontain": true, "isempty": true, "isnotempty": true, "isgreater": true, "isgreaterequal": true, "isless": true, "islessequal": true}
for i, it := range conds {
m, ok := it.(map[string]interface{})
if !ok {
problems = append(problems, fmt.Sprintf("%s.conditions[%d] 必须是对象", key, i))
continue
}
fn, _ := m["field_name"].(string)
hasRef := strings.TrimSpace(fn) != ""
if !hasRef && allowFieldID {
fid, _ := m["field_id"].(string)
hasRef = strings.TrimSpace(fid) != ""
}
if !hasRef {
problems = append(problems, fmt.Sprintf("%s.conditions[%d].field_name 不能为空", key, i))
}
op, _ := m["operator"].(string)
opKey := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(op), " ", ""))
if !allowedOps[opKey] {
problems = append(problems, fmt.Sprintf("%s.conditions[%d].operator 不支持: %s", key, i, op))
}
if opKey != "isempty" && opKey != "isnotempty" {
if _, has := m["value"]; !has {
problems = append(problems, fmt.Sprintf("%s.conditions[%d].value 缺失", key, i))
}
}
}
return problems
}
func formatDataConfigErrors(problems []string) error {
if len(problems) == 0 {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/lark-base-dashboard-block-config.md(应用页面组件见 lark-base-app-block-data-config.md)", strings.Join(problems, "\n- ")).WithParam("--data-config")
}