项目文件夹

文件
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

1183 行
34 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
batchSize = 500
baseV3ServicePath = "/open-apis/base/v3"
)
type fieldTypeSpec struct {
Type string
Extra map[string]interface{}
}
func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]interface{}, error) {
resolved, err := loadJSONInput(pc, raw, flagName)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return nil, formatJSONError(flagName, "object", err)
}
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
if result == nil {
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
return result, nil
}
func parseJSONArray(pc *parseCtx, raw string, flagName string) ([]interface{}, error) {
resolved, err := loadJSONInput(pc, raw, flagName)
if err != nil {
return nil, err
}
var result []interface{}
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
return nil, formatJSONError(flagName, "array", err)
}
return result, nil
}
func parseStringListFlexible(pc *parseCtx, raw string, flagName string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
resolved, err := loadJSONInput(pc, raw, flagName)
if err != nil {
return nil, err
}
if strings.HasPrefix(resolved, "[") {
var result []string
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
return nil, formatJSONError(flagName, "string array", err)
}
return result, nil
}
raw = resolved
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
result = append(result, item)
}
}
return result, nil
}
func parseStringList(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
result = append(result, item)
}
}
return result
}
func deepMergeMaps(dst, src map[string]interface{}) map[string]interface{} {
if dst == nil {
dst = map[string]interface{}{}
}
for key, value := range src {
if srcMap, ok := value.(map[string]interface{}); ok {
if dstMap, ok := dst[key].(map[string]interface{}); ok {
dst[key] = deepMergeMaps(dstMap, srcMap)
} else {
dst[key] = deepMergeMaps(map[string]interface{}{}, srcMap)
}
continue
}
dst[key] = value
}
return dst
}
func cloneMap(src map[string]interface{}) map[string]interface{} {
if src == nil {
return nil
}
dst := make(map[string]interface{}, len(src))
for key, value := range src {
dst[key] = cloneValue(value)
}
return dst
}
func cloneValue(value interface{}) interface{} {
switch val := value.(type) {
case map[string]interface{}:
return cloneMap(val)
case []interface{}:
cloned := make([]interface{}, len(val))
for i, item := range val {
cloned[i] = cloneValue(item)
}
return cloned
default:
return val
}
}
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
trimmed := strings.TrimSpace(typeName)
if trimmed == "" {
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
}
switch strings.ToLower(trimmed) {
case "text", "phone", "url", "email", "barcode":
return fieldTypeSpec{Type: "text"}, nil
case "number":
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "number", "formatter": "0"}}}, nil
case "currency":
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"}}}, nil
case "progress":
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"}}}, nil
case "rating":
return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5}}}, nil
case "singleselect", "single_select", "single-select":
return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": false}}, nil
case "multiselect", "multi_select", "multi-select":
return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": true}}, nil
case "datetime", "date", "date_time", "date-time":
return fieldTypeSpec{Type: "datetime", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
case "checkbox":
return fieldTypeSpec{Type: "checkbox"}, nil
case "user", "groupchat", "group_chat", "group-chat":
return fieldTypeSpec{Type: "user", Extra: map[string]interface{}{"multiple": true}}, nil
case "attachment":
return fieldTypeSpec{Type: "attachment"}, nil
case "link":
return fieldTypeSpec{Type: "link"}, nil
case "twowaylink", "two_way_link", "two-way-link":
return fieldTypeSpec{Type: "link", Extra: map[string]interface{}{"bidirectional": true}}, nil
case "formula":
return fieldTypeSpec{Type: "formula"}, nil
case "location":
return fieldTypeSpec{Type: "location"}, nil
case "autonumber", "auto_number", "auto-number":
return fieldTypeSpec{Type: "auto_number", Extra: map[string]interface{}{"style": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"type": "text", "text": "NO."}, map[string]interface{}{"type": "incremental_number", "length": 3}}}}}, nil
case "createdtime", "created_time", "created-time":
return fieldTypeSpec{Type: "created_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
case "modifiedtime", "modified_time", "modified-time":
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
default:
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
}
}
func normalizeFieldTypeName(typeName string) string {
return strings.TrimSpace(typeName)
}
func normalizeViewTypeName(typeName string) string {
trimmed := strings.TrimSpace(typeName)
if trimmed == "" {
return trimmed
}
switch strings.ToLower(trimmed) {
case "grid":
return "grid"
case "kanban":
return "kanban"
case "gallery":
return "gallery"
case "gantt":
return "gantt"
case "calendar":
return "calendar"
default:
return trimmed
}
}
func normalizeSelectOptions(raw interface{}) []interface{} {
src, ok := raw.([]interface{})
if !ok {
return nil
}
result := make([]interface{}, 0, len(src))
for _, item := range src {
switch v := item.(type) {
case string:
result = append(result, map[string]interface{}{"name": v})
case map[string]interface{}:
option := map[string]interface{}{}
if name, _ := v["name"].(string); name != "" {
option["name"] = name
}
if hue, _ := v["hue"].(string); hue != "" {
option["hue"] = hue
}
if lightness, _ := v["lightness"].(string); lightness != "" {
option["lightness"] = lightness
}
if len(option) > 0 {
result = append(result, option)
}
}
}
return result
}
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
if isPrimary {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
}
if isHidden {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
}
spec, err := resolveFieldTypeSpec(typeName)
if err != nil {
return nil, err
}
body := map[string]interface{}{
"type": spec.Type,
"name": fieldName,
}
body = deepMergeMaps(body, cloneMap(spec.Extra))
if description != "" {
_ = description
}
if uiType != "" {
switch strings.ToLower(uiType) {
case "currency":
body["type"] = "number"
body["style"] = map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"}
case "progress":
body["type"] = "number"
body["style"] = map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"}
case "rating":
body["type"] = "number"
body["style"] = map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5}
}
}
if property == nil {
return body, nil
}
property = cloneMap(property)
switch body["type"] {
case "number", "datetime", "created_at", "updated_at", "auto_number":
style, _ := body["style"].(map[string]interface{})
if style == nil {
style = map[string]interface{}{}
}
if inner, ok := property["style"].(map[string]interface{}); ok {
style = deepMergeMaps(style, inner)
delete(property, "style")
}
style = deepMergeMaps(style, property)
if len(style) > 0 {
body["style"] = style
}
case "select":
if options, ok := property["options"]; ok {
body["options"] = normalizeSelectOptions(options)
delete(property, "options")
}
if multiple, ok := property["multiple"].(bool); ok {
body["multiple"] = multiple
delete(property, "multiple")
}
body = deepMergeMaps(body, property)
case "user":
if multiple, ok := property["multiple"].(bool); ok {
body["multiple"] = multiple
delete(property, "multiple")
}
case "link":
if tableID, _ := property["table_id"].(string); tableID != "" {
body["link_table"] = tableID
delete(property, "table_id")
}
if tableID, _ := property["link_table"].(string); tableID != "" {
body["link_table"] = tableID
delete(property, "link_table")
}
if multiple, ok := property["multiple"].(bool); ok {
_ = multiple
delete(property, "multiple")
}
if backName, _ := property["back_field_name"].(string); backName != "" {
body["bidirectional"] = true
body["bidirectional_link_field_name"] = backName
delete(property, "back_field_name")
}
body = deepMergeMaps(body, property)
case "formula":
if expr, _ := property["formula_expression"].(string); expr != "" {
body["expression"] = expr
delete(property, "formula_expression")
}
if expr, _ := property["expression"].(string); expr != "" {
body["expression"] = expr
delete(property, "expression")
}
body = deepMergeMaps(body, property)
default:
body = deepMergeMaps(body, property)
}
return body, nil
}
func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{}, error) {
if rawFields != "" {
var fields []interface{}
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
}
return fields, nil
}
specs, err := parseNamedTypeSpecs(rawFieldSpecs, "field-specs")
if err != nil {
return nil, err
}
fields := make([]interface{}, 0, len(specs))
for _, spec := range specs {
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
}
fields = append(fields, body)
}
return fields, nil
}
func baseV3Path(parts ...string) string {
clean := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.Trim(part, "/")
if part != "" {
clean = append(clean, url.PathEscape(part))
}
}
return baseV3ServicePath + "/" + strings.Join(clean, "/")
}
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
for k, v := range params {
switch val := v.(type) {
case []string:
for _, item := range val {
queryParams.Add(k, item)
}
case []interface{}:
for _, item := range val {
queryParams.Add(k, fmt.Sprintf("%v", item))
}
default:
queryParams.Set(k, fmt.Sprintf("%v", v))
}
}
req := &larkcore.ApiReq{
HttpMethod: strings.ToUpper(method),
ApiPath: path,
Body: data,
QueryParams: queryParams,
}
h := make(http.Header)
h.Set("X-App-Id", runtime.Config.AppID)
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
if err != nil {
return nil, baseAPIBoundaryError(err, "API call failed")
}
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
return nil, statusErr
}
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
}
result, parseErr := decodeBaseV3Response(resp.RawBody)
if parseErr != nil {
return nil, parseErr
}
return result, nil
}
func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
var result map[string]interface{}
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
}
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
}
return result, nil
}
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
if result == nil || strings.TrimSpace(logID) == "" {
return
}
logID = strings.TrimSpace(logID)
if detail, ok := result["error"].(map[string]interface{}); ok {
if _, exists := detail["logid"]; !exists {
detail["logid"] = logID
}
return
}
data, _ := result["data"].(map[string]interface{})
if data == nil {
data = map[string]interface{}{}
result["data"] = data
}
detail, _ := data["error"].(map[string]interface{})
if detail == nil {
detail = map[string]interface{}{}
data["error"] = detail
}
if _, exists := detail["logid"]; !exists {
detail["logid"] = logID
}
}
func baseResponseLogID(resp *larkcore.ApiResp) string {
if resp == nil {
return ""
}
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
}
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
if resp == nil || resp.StatusCode < http.StatusBadRequest {
return nil
}
p, ok := errs.ProblemOf(classified)
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
return nil
}
body := strings.TrimSpace(string(resp.RawBody))
if resp.StatusCode >= http.StatusInternalServerError {
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
result, err := baseV3Raw(runtime, method, path, params, data)
return handleBaseAPIResult(result, err, "API call failed")
}
func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) {
result, err := baseV3Raw(runtime, method, path, params, data)
return handleBaseAPIResultAny(result, err, "API call failed")
}
func toInt(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case json.Number:
i, _ := n.Int64()
return int(i)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(n))
return i
default:
return 0
}
}
func toStringSlice(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
result := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
return nil, 0, err
}
rawItems, _ := data["tables"].([]interface{})
if len(rawItems) == 0 {
rawItems, _ = data["items"].([]interface{})
}
if len(rawItems) == 0 {
if _, hasID := data["id"]; hasID {
rawItems = []interface{}{data}
}
}
items := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
if m, ok := item.(map[string]interface{}); ok {
items = append(items, m)
}
}
total := toInt(data["total"])
if total == 0 {
total = len(items)
}
return items, total, nil
}
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
return nil, 0, err
}
rawItems, _ := data["fields"].([]interface{})
items := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
if m, ok := item.(map[string]interface{}); ok {
items = append(items, m)
}
}
total := toInt(data["total"])
if total == 0 {
total = len(items)
}
return items, total, nil
}
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
return nil, 0, err
}
rawItems, _ := data["views"].([]interface{})
items := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
if m, ok := item.(map[string]interface{}); ok {
items = append(items, m)
}
}
total := toInt(data["total"])
if total == 0 {
total = len(items)
}
return items, total, nil
}
func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]interface{}, error) {
for _, field := range fields {
if ref == fieldID(field) || ref == fieldName(field) {
return field, nil
}
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
}
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
for _, table := range tables {
if ref == tableID(table) || ref == tableNameFromMap(table) {
return table, nil
}
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
}
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
for _, view := range views {
if ref == viewID(view) || ref == viewName(view) {
return view, nil
}
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
}
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
if size <= 0 {
size = 1
}
chunks := [][]map[string]interface{}{}
for start := 0; start < len(records); start += size {
end := start + size
if end > len(records) {
end = len(records)
}
chunks = append(chunks, records[start:end])
}
return chunks
}
func chunkStringIDs(ids []string, size int) [][]string {
if size <= 0 {
size = 1
}
chunks := [][]string{}
for start := 0; start < len(ids); start += size {
end := start + size
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func fieldName(field map[string]interface{}) string {
if v, _ := field["name"].(string); v != "" {
return v
}
v, _ := field["field_name"].(string)
return v
}
func fieldID(field map[string]interface{}) string {
if v, _ := field["id"].(string); v != "" {
return v
}
v, _ := field["field_id"].(string)
return v
}
func fieldTypeName(field map[string]interface{}) string {
if v, _ := field["type"].(string); v != "" {
return v
}
return fmt.Sprintf("%v", field["type"])
}
func tableID(table map[string]interface{}) string {
if v, _ := table["id"].(string); v != "" {
return v
}
v, _ := table["table_id"].(string)
return v
}
func tableNameFromMap(table map[string]interface{}) string {
if v, _ := table["name"].(string); v != "" {
return v
}
v, _ := table["table_name"].(string)
return v
}
func viewID(view map[string]interface{}) string {
if v, _ := view["id"].(string); v != "" {
return v
}
v, _ := view["view_id"].(string)
return v
}
func viewName(view map[string]interface{}) string {
if v, _ := view["name"].(string); v != "" {
return v
}
v, _ := view["view_name"].(string)
return v
}
func canonicalValue(v interface{}) string {
switch val := v.(type) {
case nil:
return ""
case []interface{}:
if len(val) == 1 {
return canonicalValue(val[0])
}
case map[string]interface{}:
if id, ok := val["id"]; ok {
return canonicalValue(id)
}
if text, ok := val["text"]; ok {
return canonicalValue(text)
}
case string:
return strings.TrimSpace(val)
case float64:
if val == float64(int64(val)) {
return fmt.Sprintf("%d", int64(val))
}
}
b, _ := json.Marshal(v)
return string(b)
}
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
var tuples []interface{}
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
return nil, baseValidationErrorf("--%s invalid JSON array", flagName)
}
result := make([]namedTypeSpec, 0, len(tuples))
for idx, item := range tuples {
pair, ok := item.([]interface{})
if !ok || len(pair) != 2 {
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
}
name, ok1 := pair[0].(string)
typeName, ok2 := pair[1].(string)
if !ok1 || !ok2 {
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
}
result = append(result, namedTypeSpec{Name: name, Type: typeName})
}
return result, nil
}
type namedTypeSpec struct {
Name string
Type string
}
func selectRecordFields(items []map[string]interface{}, fields []string) []map[string]interface{} {
if len(fields) == 0 {
return items
}
result := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
entry := map[string]interface{}{}
if recordID, _ := item["record_id"].(string); recordID != "" {
entry["record_id"] = recordID
}
selected := map[string]interface{}{}
fieldMap, _ := item["fields"].(map[string]interface{})
for _, name := range fields {
if value, ok := fieldMap[name]; ok {
selected[name] = value
}
}
entry["fields"] = selected
result = append(result, entry)
}
return result
}
func compareScalar(left interface{}, right interface{}) int {
lf, lerr := strconv.ParseFloat(canonicalValue(left), 64)
rf, rerr := strconv.ParseFloat(canonicalValue(right), 64)
if lerr == nil && rerr == nil {
switch {
case lf < rf:
return -1
case lf > rf:
return 1
default:
return 0
}
}
ls := canonicalValue(left)
rs := canonicalValue(right)
switch {
case ls < rs:
return -1
case ls > rs:
return 1
default:
return 0
}
}
func asSet(v interface{}) map[string]bool {
set := map[string]bool{}
switch val := v.(type) {
case []interface{}:
for _, item := range val {
set[canonicalValue(item)] = true
}
default:
if c := canonicalValue(v); c != "" {
set[c] = true
}
}
return set
}
func valueEmpty(v interface{}) bool {
switch val := v.(type) {
case nil:
return true
case string:
return strings.TrimSpace(val) == ""
case []interface{}:
return len(val) == 0
case map[string]interface{}:
return len(val) == 0
default:
return canonicalValue(v) == ""
}
}
func matchesCondition(value interface{}, condition []interface{}) bool {
if len(condition) < 2 {
return false
}
op, _ := condition[1].(string)
var target interface{}
if len(condition) > 2 {
target = condition[2]
}
switch op {
case "==":
return compareScalar(value, target) == 0
case "!=":
return compareScalar(value, target) != 0
case ">":
return compareScalar(value, target) > 0
case ">=":
return compareScalar(value, target) >= 0
case "<":
return compareScalar(value, target) < 0
case "<=":
return compareScalar(value, target) <= 0
case "empty":
return valueEmpty(value)
case "non_empty":
return !valueEmpty(value)
case "intersects":
left := asSet(value)
right := asSet(target)
for key := range left {
if right[key] {
return true
}
}
return false
case "disjoint":
left := asSet(value)
right := asSet(target)
for key := range left {
if right[key] {
return false
}
}
return true
default:
return false
}
}
func normalizeFilterConfig(raw map[string]interface{}) (string, [][]interface{}) {
logic, _ := raw["logic"].(string)
if logic == "" {
logic, _ = raw["conjunction"].(string)
}
if logic == "" {
logic = "and"
}
rawConditions, _ := raw["conditions"].([]interface{})
conditions := make([][]interface{}, 0, len(rawConditions))
for _, item := range rawConditions {
switch cond := item.(type) {
case []interface{}:
conditions = append(conditions, cond)
case map[string]interface{}:
fieldName, ok := cond["field"]
if !ok {
fieldName = cond["field_name"]
}
conditions = append(conditions, []interface{}{fieldName, cond["operator"], cond["value"]})
}
}
return logic, conditions
}
func filterRecords(items []map[string]interface{}, filter map[string]interface{}) []map[string]interface{} {
logic, conditions := normalizeFilterConfig(filter)
if len(conditions) == 0 {
return items
}
result := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
fields, _ := item["fields"].(map[string]interface{})
matches := logic != "or"
for _, cond := range conditions {
fieldRef := canonicalValue(cond[0])
value := fields[fieldRef]
matched := matchesCondition(value, cond)
if logic == "or" {
matches = matches || matched
} else {
matches = matches && matched
}
}
if matches {
result = append(result, item)
}
}
return result
}
func normalizeSortConfig(raw []interface{}) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]interface{}); ok {
entry := map[string]interface{}{}
if field, _ := m["field"].(string); field != "" {
entry["field"] = field
} else if field, _ := m["field_name"].(string); field != "" {
entry["field"] = field
}
if desc, ok := m["desc"].(bool); ok {
entry["desc"] = desc
}
result = append(result, entry)
}
}
return result
}
func sortRecords(items []map[string]interface{}, sortConfig []interface{}) []map[string]interface{} {
normalized := normalizeSortConfig(sortConfig)
if len(normalized) == 0 {
return items
}
sorted := append([]map[string]interface{}{}, items...)
sort.SliceStable(sorted, func(i, j int) bool {
leftFields, _ := sorted[i]["fields"].(map[string]interface{})
rightFields, _ := sorted[j]["fields"].(map[string]interface{})
for _, spec := range normalized {
fieldRef, _ := spec["field"].(string)
desc, _ := spec["desc"].(bool)
cmp := compareScalar(leftFields[fieldRef], rightFields[fieldRef])
if cmp == 0 {
continue
}
if desc {
return cmp > 0
}
return cmp < 0
}
return false
})
return sorted
}
func sleepBetweenBatches(index int, total int) {
if index < total-1 {
time.Sleep(600 * time.Millisecond)
}
}
// ── Dashboard Block data_config normalization & validation ───────────
// 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{} {
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 {
if t, ok := sub["type"].(string); ok {
sub["type"] = strings.ToLower(strings.TrimSpace(t))
}
if o, ok := sub["order"].(string); ok {
sub["order"] = strings.ToLower(strings.TrimSpace(o))
}
m["sort"] = sub
}
gb[i] = m
}
}
out["group_by"] = gb
}
return out
}
// validateBlockDataConfig validates data_config based on block type.
// For text type, it checks for the presence of text field.
// For chart types, it validates table_name, series/count_all, group_by, and filter fields.
func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
var errs []string
// text 类型特殊校验:只需要有 text 字段即可
if strings.ToLower(blockType) == "text" {
if txt, _ := cfg["text"].(string); strings.TrimSpace(txt) == "" {
errs = append(errs, "text 类型组件缺少必填字段 text")
}
return errs
}
// 图表类型通用校验
// 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))
o, _ := sub["order"].(string)
o = strings.ToLower(strings.TrimSpace(o))
if t != "group" && t != "value" && t != "view" {
errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
}
if o != "asc" && o != "desc" {
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
}
}
}
}
// filter 基本结构
if f, ok := cfg["filter"].(map[string]interface{}); ok {
conj := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", f["conjunction"])))
if conj == "" {
conj = "and"
}
if conj != "and" && conj != "or" {
errs = append(errs, "filter.conjunction 仅支持 and|or")
}
if conds, ok := f["conditions"].([]interface{}); ok {
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 {
errs = append(errs, fmt.Sprintf("filter.conditions[%d] 必须是对象", i))
continue
}
fn, _ := m["field_name"].(string)
if strings.TrimSpace(fn) == "" {
errs = append(errs, fmt.Sprintf("filter.conditions[%d].field_name 不能为空", i))
}
op, _ := m["operator"].(string)
key := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(op), " ", ""))
if !allowedOps[key] {
errs = append(errs, fmt.Sprintf("filter.conditions[%d].operator 不支持: %s", i, op))
}
if key != "isempty" && key != "isnotempty" {
if _, has := m["value"]; !has {
errs = append(errs, fmt.Sprintf("filter.conditions[%d].value 缺失", i))
}
}
}
}
}
return errs
}
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/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
}