Files
dingtalk-workspace-cli/internal/helpers/sheet_create_with_data.go
huwenjiao.hwjandClaude Opus 5 332b74e8ce refactor(sheet): split create-with-data and export-csv onto their own leaves
Both capabilities were added as flags on an existing leaf, and in both cases
the leaf's published interface stopped describing what the command did:

- `sheet create --values/--sheets/--styles` orchestrates create → probe →
  resolve default worksheet → write → read back → optional styles, yet the
  leaf still published `interface_mode: mcp` + `create_workspace_sheet`.
- `sheet export --export-format csv` reads `get_range_as_csv` and never
  invokes `submit_export_job`, yet the leaf published `submit_export_job`.

Each moves to its own command, declaring the interface it actually uses:
`sheet create-with-data` is `composite` with a reviewed reason and no
`interface_ref`; `sheet export-csv` is `mcp` + `get_range_as_csv`. Both are
pinned in the interface-disposition contract test.

`sheet create` and `sheet export` are restored byte-for-byte to main, so the
compatibility gates see two `command_added` additions instead of four
locked-field changes. The split also removes a user-visible trap: `--range`
without `--export-format csv` used to be silently discarded and the whole
workbook exported; the cross-format flags no longer exist, pinned by
TestSheetExportAndExportCsvFlagsDoNotLeak.

Drops the 8 now-stale mapping-ledger exclusions that covered the flags on
`sheet.create_workspace_sheet` / `sheet.submit_export_job`, shrinking this
branch's exclusion surface. Skill references and CHANGELOG follow the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:47:50 +08:00

1412 lines
58 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.
package helpers
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// ============================================================================
// sheet create-with-data:创建表格并写入初始数据(可选样式)的编排
//
// 这是多步流程,没有单一 MCP 工具承载:
// create_workspace_sheet → 探活 → 定位默认工作表 → 写数据 → 回读校验 → 应用样式
// 所有结构/枚举校验都在发第一个请求之前完成,避免留下白建的空文档。
//
// 之所以独立成一条命令而不是给 sheet create 加 flag:sheet create 的叶子契约是
// interface_mode=mcp + interface_ref=create_workspace_sheet,即"一次直接 RPC"。
// 把编排挂到那条叶子上会让 Schema 消费者以为 --values / --sheets / --styles 都是
// create_workspace_sheet 的入参,接口审计、参数映射、后端能力判断全都拿到错误信息。
// 本叶子如实声明 interface_mode=composite(composite 不得带 interface_ref,必须给
// Reason),sheet create 则保持原样只做一次 RPC。
// ============================================================================
func newSheetCreateWithDataCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create-with-data",
Short: "创建钉钉表格文档并写入初始数据(可选样式)",
Long: `创建一篇新的钉钉在线电子表格,并在创建后写入初始数据、可选地应用样式。
创建空表格请用 dws sheet create(单次调用);本命令是多步编排:
create_workspace_sheet → 等待文档就绪 → 定位默认工作表 → 写入数据 → 回读校验 → 应用样式
中途失败会带上已创建的 nodeId 报错,便于续做或删除。
创建位置优先级: --folder > --workspace > 默认 (我的文档根目录)
初始数据(--values 与 --sheets 二选一,必须给一个):
--values 二维 JSON 数组,裸值写入默认工作表 A1 起(单表快速建表,无表头/类型语义,
复用 csv-put 通道,自动识别数字/布尔)。单元格只能是字符串/数字/布尔/null;
上限 30000 单元格、编码为 CSV 后 2000000 字符
--sheets typed table 数组,多工作表一次写入(复用 table-put 通道)。每个条目形如
{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}
name、columns 必填;第一个条目写入默认工作表(自动重命名为其 name),其余按 name 自动新建。
字段名为 camelCase,只接受 name / columns / data / dtypes / formats / cellStyles /
startCell / mode / header / allowOverwrite(写错的键会被服务端静默丢弃,故一律拒绝;
不接受 sheetId:文档尚未创建)。data 每行长度须等于 columns,单元格只能是
字符串/数字/布尔/null;dtypes、formats 的键须是 columns 里的列名(按 trim 后比较)。
单表写入上限 30000 单元格(含表头行)。
样式配置(--styles,可选;顶层键对齐飞书 snake_case,列表项内字段兼容 camelCase;
两级都拒绝未知键,避免写错的键被静默丢弃导致样式只应用一半):
{"styles":[{"name":"表名",
"cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#FFF2CC",
"font_family":"微软雅黑","number_format":"@",
"border_styles":{"bottom":{"style":"medium"}}}],
"row_sizes":[{"range":"1:1","type":"pixel","size":28}],
"col_sizes":[{"range":"A:D","type":"pixel","size":120}],
"cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]}
- 每项至少给 cell_styles / row_sizes / col_sizes / cell_merges 之一
- 配 --sheets 时 styles 的项数/顺序/name 必须与 --sheets 子表一一对应;配 --values 时只给 1 项(name 忽略)
- 数据写入后按 cell_styles → row_sizes → col_sizes → cell_merges 顺序执行(非原子)
- row_sizes 的 type:pixel(需 size)/ standard(恢复默认行高)/ auto(按内容自适应)
- col_sizes 的 type:pixel(需 size)/ standard(恢复默认列宽)——与飞书一致,列宽不提供 auto
- merge_type 取 all/rows/columns`,
Example: ` # 创建并写入初始数据(默认工作表,裸二维值)
dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]'
# 创建多个带数据的工作表(typed table)
dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]'
# 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽、合并)
dws sheet create-with-data --name "带样式" --values '[["姓名","分数"],["张三","90"]]' \
--styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold","background_color":"#FFF2CC"}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}],"col_sizes":[{"range":"A:B","type":"pixel","size":120}]}]}'
# 指定创建位置
dws sheet create-with-data --name "Q1 数据" --folder FOLDER_ID --values '[["月份","金额"],["1月",100]]'`,
RunE: func(cmd *cobra.Command, _ []string) error {
valuesStr, _ := cmd.Flags().GetString("values")
sheetsStr, _ := cmd.Flags().GetString("sheets")
stylesStr, _ := cmd.Flags().GetString("styles")
if valuesStr == "" && sheetsStr == "" {
return fmt.Errorf("--values 与 --sheets 必须给一个(只建空表格请用 dws sheet create)")
}
if valuesStr != "" && sheetsStr != "" {
return fmt.Errorf("--values 与 --sheets 二选一,不能同时指定")
}
createArgs := map[string]any{
"name": mustGetFlag(cmd, "name"),
}
if v, _ := cmd.Flags().GetString("folder"); v != "" {
createArgs["folderId"] = v
}
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
createArgs["workspaceId"] = v
}
return runCreateSheetWithData(cmd, createArgs, valuesStr, sheetsStr, stylesStr)
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "create_with_data",
CanonicalPath: "sheet.create_with_data",
CLIPath: "sheet create-with-data",
PrimaryCLIPath: "sheet create-with-data",
},
Description: "创建钉钉在线电子表格文档(axls)并写入初始数据,可选一并应用样式。",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
},
Selection: contract.SelectionSpec{
AgentSummary: "创建钉钉在线电子表格文档(axls)并写入初始数据,可选一并应用样式。",
UseWhen: []string{"需要新建一份钉钉在线电子表格并在建表时就带上初始数据(可含表头样式、行高列宽、合并)时"},
AvoidWhen: []string{"只要空表格用 sheet create;往已有表格写数据用 sheet csv-put / table-put;只改样式用 sheet range set-style"},
Examples: []string{
"dws sheet create-with-data --name \"名单\" --values '[[\"姓名\",\"分数\"],[\"张三\",90]]'",
"dws sheet create-with-data --name \"报表\" --sheets '[{\"name\":\"一月\",\"columns\":[\"项目\",\"金额\"],\"data\":[[\"房租\",5000]]}]'",
},
},
// name 由 mustGetFlag 校验;这里发布 required 供 Schema 消费者识别
Parameters: []contract.ParamDecl{
{Name: "name", Required: boolPtr(true)},
},
},
})
cmd.Flags().String("name", "", "表格名称 (必填)")
cmd.Flags().String("folder", "", "目标文件夹 ID 或 URL")
cmd.Flags().String("workspace", "", "目标知识库 ID")
cmd.Flags().String("values", "", "初始数据,二维 JSON 数组,写入默认工作表 (与 --sheets 二选一)")
cmd.Flags().String("sheets", "", `多工作表 typed table JSON,如 '[{"name":"表名","columns":[...],"data":[[...]]}]' (与 --values 二选一)`)
cmd.Flags().String("styles", "", `写入数据后一并应用的视觉处理 JSON(对齐飞书):{"styles":[{"name":"表名","cell_styles":[{"range":"A1:D1","font_weight":"bold"}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}],"col_sizes":[{"range":"A:D","type":"pixel","size":120}],"cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]}`)
return cmd
}
func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, valuesStr, sheetsStr, stylesStr string) error {
// 先解析初始数据,避免创建了空文档后才发现 JSON 非法
var values [][]any
var sheetSpecs []map[string]any
// 分流判据统一用 valuesStr(flag 是否给了),不能用 values != nil:
// `--values null` 是合法 JSON 且解析出 nil,两者不等价。
useValues := valuesStr != ""
if useValues {
// UseNumber 保留数字字面量,不经过 float64:普通 Unmarshal 会把超过 2^53
// 的整数舍入(雪花 ID 1234567890123456789 变成 ...768),而回读只校验
// 单元格非空,这种篡改会被当成写入成功。订单号、雪花 ID 都是表格常见数据。
dec := json.NewDecoder(strings.NewReader(valuesStr))
dec.UseNumber()
if err := dec.Decode(&values); err != nil {
return fmt.Errorf("--values JSON 解析失败: %w", err)
}
if err := requireJSONEOF(dec, "--values"); err != nil {
return err
}
// null / [] 都写不出任何数据,必须在建文档之前拒掉,
// 否则会白建一个空文档再由回读校验报成"写入未生效"。
if len(values) == 0 {
return fmt.Errorf("--values 不能为空(需要形如 '[[\"姓名\",\"分数\"],[\"张三\",90]]' 的二维数组)")
}
if err := validateValuesCells(values); err != nil {
return err
}
if !valuesHaveContent(values) {
return fmt.Errorf("--values 全部单元格为空,写不出任何数据(需要至少一个非空单元格)")
}
if err := validateValuesBudget(values); err != nil {
return err
}
} else {
specs, err := parseCreateSheetSpecs(sheetsStr)
if err != nil {
return err
}
sheetSpecs = specs
}
// --styles 也先解析校验(含与 --sheets 的数量/顺序/name 一致性)
var styleOps []sheetStyleOps
if stylesStr != "" {
var expected []string
for _, s := range sheetSpecs {
name, _ := s["name"].(string)
expected = append(expected, name)
}
ops, err := parseCreateStyles(stylesStr, expected)
if err != nil {
return err
}
// 前置校验:在创建文档之前把每项的结构/枚举问题全部暴露(快速失败,避免白建空文档)
for i, o := range ops {
if _, err := planStyleOps("", "", o); err != nil {
return fmt.Errorf("--styles[%d]: %w", i, err)
}
}
styleOps = ops
}
ctx := context.Background()
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "创建表格并写入初始数据")
deps.Out.PrintKeyValue("名称", fmt.Sprintf("%v", createArgs["name"]))
return nil
}
// json 模式下进度提示会污染 stdout,末尾统一输出创建结果 JSON。
jsonMode := deps.Caller.Format() == "json"
progress := func(msg string) {
if !jsonMode {
deps.Out.PrintInfo(msg)
}
}
progress("创建表格文档 ...")
createText, err := callMCPToolReturnText(ctx, "create_workspace_sheet", createArgs)
if err != nil {
return fmt.Errorf("创建表格失败: %w", err)
}
nodeID, err := parseCreatedNodeID(createText)
if err != nil {
return err
}
progress(fmt.Sprintf("表格已创建: nodeId=%s,等待文档就绪 ...", nodeID))
// 新建文档服务端仍在初始化,需先探活;否则写入可能返回成功但数据不落盘
if err := waitSheetWritable(ctx, nodeID); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但等待文档就绪失败: %w", nodeID, err)
}
// 定位默认工作表
defaultSheetID, err := resolveFirstSheetID(ctx, nodeID)
if err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但定位默认工作表失败: %w", nodeID, err)
}
progress("开始写入初始数据 ...")
if useValues {
if err := writeValuesToSheet(ctx, nodeID, defaultSheetID, values); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但写入数据失败: %w", nodeID, err)
}
// 回读校验:防"返回成功但未落盘"(新建文档初始化竞态)。
// 必须校验输入里第一个**非空**的单元格,不能死盯 A1 ——
// [["","姓名"],[1,"张三"]] 这类首格为空的合法数据会被误报成写入失败。
probeCell := firstNonEmptyValuesCell(values)
if err := verifyRangeNotEmpty(ctx, nodeID, defaultSheetID, probeCell); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但初始数据写入未生效: %w;请对该文档重新执行 csv-put/range update 补写", nodeID, err)
}
} else {
// 复用默认工作表承载第一个 sheet:先把默认表重命名为 sheets[0].name,
// 之后 table_put 按 name 命中它并写入;其余 sheet 由 table_put 自动创建。
// 避免残留一张空的默认工作表。
firstName, _ := sheetSpecs[0]["name"].(string)
if err := callMCPToolSilent(ctx, "update_sheet", map[string]any{
"nodeId": nodeID, "sheetId": defaultSheetID, "title": firstName,
}); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但重命名默认工作表失败: %w", nodeID, err)
}
if err := callMCPToolSilent(ctx, "table_put", map[string]any{
"nodeId": nodeID,
"sheets": sheetSpecs,
}); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但写入初始数据失败: %w", nodeID, err)
}
// 逐表回读校验:table_put 可能返回成功但在新建文档初始化竞态下数据未落盘,
// 与 --values 分支同源的问题。table_put 会按 name 复用/新建工作表,因此
// 重取一次 name→sheetId 映射,再按每个 spec 的首个预期非空单元格回读。
sheetIDByName, err := resolveSheetIDsByName(ctx, nodeID)
if err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),数据已提交但无法回读校验(获取工作表列表失败): %w;请自行确认各工作表数据是否落盘,必要时用 sheet table-put 补写", nodeID, err)
}
for _, spec := range sheetSpecs {
name, _ := spec["name"].(string)
probeCell, hasContent := firstNonEmptySheetSpecCell(spec)
if !hasContent {
continue // 只有 name、无 columns/data 的工作表本就应为空,不回读
}
sid, ok := sheetIDByName[name]
if !ok {
return fmt.Errorf("表格已创建 (nodeId=%s),但写入后未找到工作表 %q,其初始数据可能未落盘;请用 sheet table-put 对该文档补写", nodeID, name)
}
if err := verifyRangeNotEmpty(ctx, nodeID, sid, probeCell); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但工作表 %q 的初始数据写入未生效: %w;请用 sheet table-put 对该文档补写", nodeID, name, err)
}
}
}
progress("初始数据写入完成。")
// --styles:数据写入后按 cell_styles → row_sizes → col_sizes → cell_merges 顺序应用
if len(styleOps) > 0 {
progress("应用样式配置 ...")
for i, ops := range styleOps {
// --values 模式只有一项,作用于默认表;--sheets 模式按顺序对应各子表(name 已校验一致)
targetSheet := defaultSheetID
if !useValues {
targetSheet = ops.Name
}
if err := applyStyleOps(ctx, nodeID, targetSheet, ops); err != nil {
return fmt.Errorf("表格已创建且数据已写入 (nodeId=%s),但 --styles[%d] 应用失败: %w", nodeID, i, err)
}
}
progress("样式应用完成。")
}
// 输出创建结果(含 nodeId / docUrl)
deps.Out.PrintRaw(createText)
return nil
}
// parseCreateSheetSpecs 解析 --sheets 为 table_put 的 sheet spec 数组,并校验每个条目带 name。
// 接受 JSON 数组、{"sheets":[...]} 或单个 spec 对象。
func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) {
var payload any
// 同 --values:UseNumber 保留数字字面量。specs 原样转发给 table_put,
// 若经过 float64 中转,records/data 里的大整数会在发出前就被改掉
// (1234567890123456789 变成 1234567890123456800)。
dec := json.NewDecoder(strings.NewReader(sheetsStr))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return nil, fmt.Errorf("--sheets JSON 解析失败: %w", err)
}
if err := requireJSONEOF(dec, "--sheets"); err != nil {
return nil, err
}
var arr []any
switch v := payload.(type) {
case []any:
arr = v
case map[string]any:
// 只要出现 sheets 键就按包装对象处理:畸形包装({"sheets":"bad","name":"一月"})
// 必须报错,不能退化成「把整个对象当单个 spec」—— 那样 sheets 字段会被当成
// 未知字段,用户以为在传一组子表,实际只建了一张。
if inner, wrapped := v["sheets"]; wrapped {
s, ok := inner.([]any)
if !ok {
return nil, fmt.Errorf("--sheets.sheets 必须是数组,实际是 %T", inner)
}
arr = s
} else {
arr = []any{v} // 单个 spec 对象
}
default:
return nil, fmt.Errorf("--sheets 必须是 JSON 数组、{\"sheets\":[...]} 或单个 sheet spec 对象")
}
if len(arr) == 0 {
return nil, fmt.Errorf("--sheets 不能为空数组")
}
specs := make([]map[string]any, 0, len(arr))
seen := make(map[string]int, len(arr))
for i, item := range arr {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("--sheets[%d] 不是对象", i)
}
if v, present := m["name"]; present && v != nil {
if _, isStr := v.(string); !isStr {
return nil, fmt.Errorf("--sheets[%d].name 必须是字符串,实际是 %T", i, v)
}
}
name, _ := m["name"].(string)
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("--sheets[%d] 缺少必填的 name 字段(创建带数据时每个工作表必须命名)", i)
}
// 工作表名不能重复:样式接口按「ID 或名称」定位工作表,重名时命中哪一张
// 由服务端决定,--styles 会作用到不确定的工作表上。而 --styles 又是建
// 文档之后的非原子步骤,所以必须在建文档之前拒掉。
if prev, dup := seen[name]; dup {
return nil, fmt.Errorf("--sheets[%d].name=%q 与 --sheets[%d] 重复;工作表名必须唯一,否则 --styles 按名称定位时会落到不确定的工作表上", i, name, prev)
}
seen[name] = i
// name 之外的字段同样要在建文档之前按 table_put 的输入契约验一遍
if err := validateCreateSheetSpec(m); err != nil {
return nil, fmt.Errorf("--sheets[%d]: %w", i, err)
}
specs = append(specs, m)
}
return specs, nil
}
// ── --sheets:table_put 输入契约的前置校验 ────────────────────────────────────
//
// sheet spec 原样转发给 table_put,字段契约见 MCP 的 SheetTableSpec 与引擎侧
// TablePutOptions:
//
// name / startCell / mode / header / allowOverwrite /
// columns(必填、非空、列名非空且不重复)/ data / dtypes / formats / cellStyles
//
// 只校验「对象类型 + name」是不够的:
// - 类型不对的字段(data 传成对象、columns 传成字符串)要等到 table_put 才被
// 服务端拒绝,此时 create_workspace_sheet 与 update_sheet 已经执行,留下一份
// 用户并没有请求到的空/半成品文档,且无法回滚。
// - 拼错的字段更糟:MCP 侧反序列化到 SheetTableSpec 会静默丢掉不认识的键,
// `datas` 会变成「只写了表头、数据全丢」,而回读探针刚好落在表头上,
// 整条命令报成功。
//
// 因此把契约里的每个已提供字段都在发第一个请求之前验完。
// requireJSONEOF 确认解码器在读出第一个 JSON 值之后就到了输入末尾。
//
// json.Decoder.Decode 只读一个值就返回,`[[1]] trailing` 会被当成合法矩阵:
// 粘贴多了一段、shell 拼接残留、把两个 JSON 首尾相连,都会静默丢掉后半截并照常
// 建文档。创建不可原子回滚,所以这类输入必须在发第一个请求之前拒掉,而不是建完
// 文档只写进去半份数据。(--styles 走 json.Unmarshal,它本身就拒绝多余内容。)
func requireJSONEOF(dec *json.Decoder, flag string) error {
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
return fmt.Errorf("%s 只接受单个 JSON 值,末尾有多余内容", flag)
}
return nil
}
const maxTableWriteCells = 30000
// tablePutSpecFields 是 table_put 单个 sheet spec 的合法字段(契约字段名为 camelCase)。
// sheetId 虽在契约内但不在此列:见 validateCreateSheetSpec。
var tablePutSpecFields = []string{
"name", "startCell", "mode", "header", "allowOverwrite",
"columns", "data", "dtypes", "formats", "cellStyles",
}
// looseFieldKey 归一化字段名:去掉下划线/连字符并转小写。
func looseFieldKey(s string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(s))
}
// rejectUnknownFields 校验对象只含契约里的字段名。allowed 列出所有被接受的拼写
// (同一字段的多种别名都要列出,首个视为规范拼写);归一化后能对上的键判为拼写
// 偏差并直接指向规范拼写,其余判为未知字段。
//
// 之所以要拒而不是忽略:这些 JSON 最终交给「按固定字段名取值」的下游(服务端
// DTO 或 pickStr),不认识的键会被静默丢掉——写错一个键的后果是数据/样式部分丢失
// 而命令报成功,比直接报错难发现得多。
func rejectUnknownFields(item map[string]any, what string, allowed []string) error {
exact := make(map[string]bool, len(allowed))
canonicalByLoose := make(map[string]string, len(allowed))
for _, f := range allowed {
exact[f] = true
if _, dup := canonicalByLoose[looseFieldKey(f)]; !dup {
canonicalByLoose[looseFieldKey(f)] = f
}
}
canonical := make([]string, 0, len(canonicalByLoose))
for _, f := range allowed {
if canonicalByLoose[looseFieldKey(f)] == f {
canonical = append(canonical, f)
}
}
for _, key := range sortedMapKeys(item) {
if exact[key] {
continue
}
if want, ok := canonicalByLoose[looseFieldKey(key)]; ok {
return fmt.Errorf("字段 %q 拼写不符合契约,应为 %q", key, want)
}
return fmt.Errorf("未知字段 %q(%s只接受 %s)", key, what, strings.Join(canonical, " / "))
}
return nil
}
// normalizeSpecStartCell 把 startCell 归一化成 parseA1Cell 认的形态:服务端解析
// 前会 toUpperCase,且允许 $ 绝对引用($C$3 等价于 C3)。本地校验与回读探针必须与
// 服务端同一套解释——否则要么误拒服务端接受的写法,要么探到错位的单元格。
func normalizeSpecStartCell(s string) string {
return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(s), "$", ""))
}
// sortedMapKeys 返回排序后的键,保证校验顺序与错误信息稳定(map 遍历是随机的)。
func sortedMapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// validateCreateSheetSpec 按 table_put 的输入契约校验单个 sheet spec 的每个已提供
// 字段。纯函数、不发请求:调用方在创建文档之前调用,非法输入不会产生任何 MCP 调用。
func validateCreateSheetSpec(spec map[string]any) error {
// sheetId 在 table_put 契约里合法(且优先级高于 name),但对 create-with-data
// 没有意义:文档此刻还不存在,任何 sheetId 都不可能是它的工作表。传了只会
// 让写入落到别处或直接失败,而重命名默认表、回读校验都是按 name 定位的。
for _, key := range sortedMapKeys(spec) {
if looseFieldKey(key) == "sheetid" {
return fmt.Errorf("不支持 sheetId:文档尚未创建,工作表 ID 无从得知;请只用 name 指定工作表")
}
}
if err := rejectUnknownFields(spec, "sheet spec ", tablePutSpecFields); err != nil {
return err
}
columns, err := validateSpecColumns(spec)
if err != nil {
return err
}
dataRows, err := validateSpecData(spec, columns)
if err != nil {
return err
}
for _, field := range []string{"dtypes", "formats"} {
if err := validateSpecColumnMap(spec, field, columns); err != nil {
return err
}
}
if v, ok := spec["cellStyles"]; ok && v != nil {
switch v.(type) {
case map[string]any, []any:
default:
return fmt.Errorf("cellStyles 必须是对象或二维数组,实际是 %T", v)
}
}
// mode / startCell 留空等价于不传(服务端按 blank 处理),非空才校验取值。
if v, ok := spec["mode"]; ok && v != nil {
s, isStr := v.(string)
if !isStr {
return fmt.Errorf("mode 必须是字符串,实际是 %T", v)
}
if strings.TrimSpace(s) != "" && s != "overwrite" && s != "append" {
return fmt.Errorf("mode=%q 非法(合法值: overwrite / append)", s)
}
}
for _, field := range []string{"header", "allowOverwrite"} {
if v, ok := spec[field]; ok && v != nil {
if _, isBool := v.(bool); !isBool {
return fmt.Errorf("%s 必须是布尔值,实际是 %T", field, v)
}
}
}
if v, ok := spec["startCell"]; ok && v != nil {
s, isStr := v.(string)
if !isStr {
return fmt.Errorf("startCell 必须是字符串,实际是 %T", v)
}
if normalized := normalizeSpecStartCell(s); normalized != "" {
if _, _, err := parseA1Cell(normalized); err != nil {
return fmt.Errorf("startCell=%q 不是合法的单元格地址(应形如 A1,单个单元格)", s)
}
}
}
// 写入规模上限是服务端硬限,超限同样只会在 table_put 阶段报错。
rows := dataRows
if specWritesHeader(spec) {
rows++
}
if total := rows * len(columns); total > maxTableWriteCells {
return fmt.Errorf("单个工作表写入单元格总数上限为 %d(当前 %d 行 × %d 列 = %d)",
maxTableWriteCells, rows, len(columns), total)
}
return nil
}
// validateSpecColumns 校验必填的 columns 并返回服务端实际使用的列名(已 trim)。
func validateSpecColumns(spec map[string]any) ([]string, error) {
raw, ok := spec["columns"]
if !ok || raw == nil {
return nil, fmt.Errorf("缺少必填的 columns(table_put 要求每个工作表给出非空的列名数组)")
}
list, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("columns 必须是字符串数组,实际是 %T", raw)
}
if len(list) == 0 {
return nil, fmt.Errorf("columns 不能为空数组")
}
columns := make([]string, 0, len(list))
seen := make(map[string]int, len(list))
for i, item := range list {
s, isStr := item.(string)
if !isStr {
return nil, fmt.Errorf("columns[%d] 必须是字符串,实际是 %T", i, item)
}
// 服务端按 trim 后的列名判重、写表头、并以其作为 dtypes/formats 的查表键,
// 本地校验必须用同一套归一化,否则 ["量","量 "] 会通过本地检查再被服务端拒绝。
name := strings.TrimSpace(s)
if name == "" {
return nil, fmt.Errorf("columns[%d] 不能为空字符串(列名不可为空)", i)
}
if prev, dup := seen[name]; dup {
return nil, fmt.Errorf("columns[%d]=%q 与 columns[%d] 重复(列名不可重复)", i, name, prev)
}
seen[name] = i
columns = append(columns, name)
}
return columns, nil
}
// validateSpecData 校验可选的 data 形状(二维、行宽等于 columns、单元格为标量),
// 返回数据行数供写入规模校验使用。data 缺省或为 null 等价于空数据。
func validateSpecData(spec map[string]any, columns []string) (int, error) {
raw, ok := spec["data"]
if !ok || raw == nil {
return 0, nil
}
rows, ok := raw.([]any)
if !ok {
return 0, fmt.Errorf("data 必须是二维数组,实际是 %T", raw)
}
for i, r := range rows {
row, isRow := r.([]any)
if !isRow {
return 0, fmt.Errorf("data[%d] 必须是数组,实际是 %T", i, r)
}
if len(row) != len(columns) {
return 0, fmt.Errorf("data[%d] 有 %d 列,与 columns 的 %d 列不一致(每行长度必须与 columns 一致)",
i, len(row), len(columns))
}
for j, cell := range row {
if !isTableScalar(cell) {
return 0, fmt.Errorf("data[%d][%d] 必须是字符串/数字/布尔/null,实际是 %T(不支持嵌套数组或对象)", i, j, cell)
}
}
}
return len(rows), nil
}
// validateSpecColumnMap 校验 dtypes / formats:必须是「列名 → 字符串」的对象,且键
// 必须是 columns 里的列名 —— 服务端按列名查表,键写错既不报错也不生效,静默失效
// 比报错更难发现。
func validateSpecColumnMap(spec map[string]any, field string, columns []string) error {
raw, ok := spec[field]
if !ok || raw == nil {
return nil
}
m, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("%s 必须是对象(列名 → 字符串),实际是 %T", field, raw)
}
known := make(map[string]bool, len(columns))
for _, c := range columns {
known[c] = true
}
for _, key := range sortedMapKeys(m) {
if _, isStr := m[key].(string); !isStr {
return fmt.Errorf("%s[%q] 必须是字符串,实际是 %T", field, key, m[key])
}
if !known[key] {
return fmt.Errorf("%s 的键 %q 不是 columns 中的列名(服务端按列名查表,会静默忽略)", field, key)
}
}
return nil
}
// isTableScalar 判断单元格取值是否落在契约允许的标量范围(string / number /
// boolean / null)。嵌套数组或对象会被服务端拒绝,或(--values 通道)被 fmt 写成
// "map[a:1]" 这类无意义文本。UseNumber 解码出的数字是 json.Number。
func isTableScalar(v any) bool {
switch v.(type) {
case nil, string, bool, json.Number, float64, int:
return true
}
return false
}
// specWritesHeader 判断该 spec 是否会写入表头行:显式 header 优先;缺省时写表头
// (overwrite 默认写;append 落在空表上也写,而本命令的工作表都是新建空表)。
func specWritesHeader(spec map[string]any) bool {
if v, ok := spec["header"].(bool); ok {
return v
}
return true
}
// validateValuesCells 校验 --values 的单元格取值同样是标量。嵌套对象/数组会被
// cellToString 写成 "map[a:1]",回读校验只看非空,于是垃圾数据被报成写入成功。
func validateValuesCells(values [][]any) error {
for i, row := range values {
for j, cell := range row {
if !isTableScalar(cell) {
return fmt.Errorf("--values[%d][%d] 必须是字符串/数字/布尔/null,实际是 %T"+
"(不支持嵌套数组或对象;富格式单元格请用 sheet range update)", i, j, cell)
}
}
}
return nil
}
// validateValuesBudget 校验 --values 不超过 set_range_from_csv 的服务端硬限
// (行 × 列 ≤ 30000,CSV ≤ 2M 字符)。超限只会在写入阶段失败,而那时文档已经建好。
func validateValuesBudget(values [][]any) error {
cols := 0
for _, row := range values {
if len(row) > cols {
cols = len(row)
}
}
if total := len(values) * cols; total > maxCSVWriteCells {
return fmt.Errorf("--values 单元格总数上限为 %d(当前 %d 行 × %d 列 = %d)",
maxCSVWriteCells, len(values), cols, total)
}
if n := utf8.RuneCountInString(valuesToCSV(values)); n > maxCSVWriteChars {
return fmt.Errorf("--values 编码为 CSV 后长度为 %d 字符,超过上限 %d", n, maxCSVWriteChars)
}
return nil
}
// set_range_from_csv 的服务端硬限:解析后行 × 列 ≤ 30000,csv 文本 ≤ 2M 字符。
const (
maxCSVWriteCells = 30000
maxCSVWriteChars = 2 * 1000 * 1000
)
// ── create-with-data --styles:对齐飞书 +workbook-create --styles 协议 ────────
//
// {"styles":[{"name":"子表名",
//
// "cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#FFF2CC",
// "border_styles":{"bottom":{"style":"medium"}}}],
// "row_sizes":[{"range":"1:1","type":"pixel","size":28}],
// "col_sizes":[{"range":"A:D","type":"pixel","size":120}],
// "cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]}
//
// 顶层四个列表键与飞书一致(snake_case);列表项内部的字段名同时兼容 DWS 的
// camelCase 写法。两处都拒绝未知键:这些 JSON 落到固定字段名的结构体/pickStr 上,
// 写错的键会被静默丢弃,导致样式只应用了一部分而命令报成功。
//
// styleItemFields / cellStyleItemFields / sizeItemFields / mergeItemFields 必须与
// sheetStyleOps 的 json tag、cellStyleItemToSpec 与 planStyleOps 里的取值键保持一致。
var (
styleItemFields = []string{"name", "cell_styles", "row_sizes", "col_sizes", "cell_merges"}
cellStyleItemFields = []string{
"range",
"background_color", "backgroundColor", "bgColor",
"font_color", "fontColor",
"font_family", "fontFamily",
"font_style", "fontStyle",
"font_weight", "fontWeight",
"font_line", "fontLine",
"font_size", "fontSize",
"horizontal_alignment", "horizontalAlignment", "hAlign",
"vertical_alignment", "verticalAlignment", "vAlign",
"word_wrap", "wordWrap",
"number_format", "numberFormat",
"border_styles", "borderStyles",
}
sizeItemFields = []string{"range", "type", "size"}
mergeItemFields = []string{"range", "merge_type", "mergeType"}
)
// sheetStyleOps 是 --styles 数组的单项:对应一张子表的视觉处理操作。
type sheetStyleOps struct {
Name string `json:"name"`
CellStyles []map[string]any `json:"cell_styles"`
RowSizes []map[string]any `json:"row_sizes"`
ColSizes []map[string]any `json:"col_sizes"`
CellMerges []map[string]any `json:"cell_merges"`
}
func (o sheetStyleOps) isEmpty() bool {
return len(o.CellStyles) == 0 && len(o.RowSizes) == 0 && len(o.ColSizes) == 0 && len(o.CellMerges) == 0
}
// pickStr 按多个候选键取字符串(snake_case 优先,兼容 camelCase)。
func pickStr(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok && v != nil {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return ""
}
// pickNum 按多个候选键取整数值(JSON 数字为 float64)。
//
// JSON 解码后所有数字都是 float64,直接 int(n) 会把 12.9 悄悄变成 12、把 1e20
// 截成 math.MaxInt64。文档和错误信息都写明这些字段必须是正整数,而 --styles
// 是不可原子回滚的复合流程,静默取整会留下与配置不符的表格。因此非整数、
// 非有限值、超出 int 范围一律报错,交由调用方在建文档之前拒绝。
func pickNum(m map[string]any, keys ...string) (int, bool, error) {
for _, k := range keys {
v, ok := m[k]
if !ok || v == nil {
continue
}
switch n := v.(type) {
case float64:
if math.IsNaN(n) || math.IsInf(n, 0) {
return 0, true, fmt.Errorf("%s=%v 不是有限数值", k, n)
}
if n != math.Trunc(n) {
return 0, true, fmt.Errorf("%s=%v 必须是整数,不接受小数(静默取整会得到与配置不符的结果)", k, n)
}
if n > math.MaxInt32 || n < math.MinInt32 {
return 0, true, fmt.Errorf("%s=%v 超出取值范围", k, n)
}
return int(n), true, nil
case int:
return n, true, nil
default:
return 0, true, fmt.Errorf("%s 必须是数字,实际是 %T", k, v)
}
}
return 0, false, nil
}
// feishuWordWrap 把飞书 word_wrap 枚举映射为引擎枚举(同时接受引擎原生值)。
func feishuWordWrap(v string) string {
switch v {
case "auto-wrap":
return "autoWrap"
case "word-clip":
return "clip"
default:
return v // overflow / autoWrap / clip 原样
}
}
// mergeCellsTypeEnum 是 merge_cells 接受的原生 mergeType 取值。
var mergeCellsTypeEnum = map[string]bool{
"mergeAll": true, "mergeRows": true, "mergeColumns": true,
}
// feishuMergeType 把飞书 merge_type 映射为 MCP mergeType(同时接受原生值)。
//
// 未知值必须报错而不能原样透传:planStyleOps 是创建文档**之前**的最后一道枚举
// 校验,放过去的后果是「文档已建 → 数据已写 → cell_styles/row_sizes/col_sizes
// 已刷 → 最后 merge_cells 被服务端拒绝」,留下一个部分修改过的新文档,正是这段
// 编排声称要避免的非原子副作用。
func feishuMergeType(v string) (string, error) {
switch v {
case "", "all":
return "mergeAll", nil
case "rows":
return "mergeRows", nil
case "columns":
return "mergeColumns", nil
}
if mergeCellsTypeEnum[v] {
return v, nil
}
return "", fmt.Errorf("merge_type 非法: %q(合法值: all / rows / columns,或原生 mergeAll / mergeRows / mergeColumns)", v)
}
// cellStyleItemToSpec 把飞书 cell_styles 单项转为 styleSpec + range,复用 buildStyleCells 的校验。
func cellStyleItemToSpec(item map[string]any) (*styleSpec, string, error) {
if err := rejectUnknownFields(item, "cell_styles 项", cellStyleItemFields); err != nil {
return nil, "", err
}
rangeAddr := pickStr(item, "range")
if rangeAddr == "" {
return nil, "", fmt.Errorf("cell_styles 项缺少必填的 range")
}
spec := &styleSpec{
BgColor: pickStr(item, "background_color", "backgroundColor", "bgColor"),
FontColor: pickStr(item, "font_color", "fontColor"),
FontFamily: pickStr(item, "font_family", "fontFamily"),
FontStyle: pickStr(item, "font_style", "fontStyle"),
FontWeight: pickStr(item, "font_weight", "fontWeight"),
FontLine: pickStr(item, "font_line", "fontLine"),
HAlign: pickStr(item, "horizontal_alignment", "horizontalAlignment", "hAlign"),
VAlign: pickStr(item, "vertical_alignment", "verticalAlignment", "vAlign"),
WordWrap: feishuWordWrap(pickStr(item, "word_wrap", "wordWrap")),
NumberFormat: pickStr(item, "number_format", "numberFormat"),
}
if n, ok, err := pickNum(item, "font_size", "fontSize"); err != nil {
return nil, "", fmt.Errorf("cell_styles.%w", err)
} else if ok {
spec.FontSize = n
}
// border_styles 是对象,转回 JSON 字符串交给已有的 parseBorderStyles 校验。
// 入参来自 json.Unmarshal,不含 channel/func/NaN,所以 Marshal 不会失败。
for _, k := range []string{"border_styles", "borderStyles"} {
if bs, ok := item[k]; ok && bs != nil {
raw, _ := json.Marshal(bs)
spec.BorderStylesJSON = string(raw)
break
}
}
return spec, rangeAddr, nil
}
// isAllLetters 判断字符串是否非空且只含英文字母(大小写皆可),用于列范围
// 预检:拒绝 "A5" / "Ax" 这类带数字或尾随字符、会被 parseA1Cell 误判的列名。
func isAllLetters(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
return false
}
}
return true
}
// parseRowColRange 解析行/列范围:"1:3"→(start "1", len 3);"A:C"→(start "A", len 3);"1"/"A"→len 1。
func parseRowColRange(addr string, isRow bool) (start string, length int, err error) {
addr = strings.TrimSpace(addr)
if i := strings.Index(addr, "!"); i >= 0 {
addr = addr[i+1:]
}
if addr == "" {
return "", 0, fmt.Errorf("range 不能为空")
}
parts := strings.SplitN(addr, ":", 2)
a := strings.TrimSpace(parts[0])
b := a
if len(parts) == 2 {
b = strings.TrimSpace(parts[1])
}
if isRow {
// strconv.Atoi 要求整个字符串都是合法整数,拒绝 "1x" / "2foo" 这类带尾随
// 字符的输入。fmt.Sscanf("%d") 只消费前缀数字,会把 "1x" 静默当成第 1 行 ——
// 这是建文档前的校验关口,放过后 update_dimension 会改到错误的行,且此时
// 文档与数据已创建、无法原子回滚。
r1, e1 := strconv.Atoi(a)
r2, e2 := strconv.Atoi(b)
if e1 != nil || e2 != nil || r1 < 1 || r2 < 1 {
return "", 0, fmt.Errorf("行范围非法: %s(应形如 \"1:3\")", addr)
}
if r2 < r1 {
r1, r2 = r2, r1
}
return fmt.Sprintf("%d", r1), r2 - r1 + 1, nil
}
// 列同理必须是纯字母:parseA1Cell 会给列名补 "1" 再解析,"A5" 会变成 "A51"
// 被当成 A 列静默放过。先要求 a/b 只含字母,杜绝带数字/尾随字符的输入通过预检;
// 纯多字母列名(如 "AX")是合法的,正常通过。
if !isAllLetters(a) || !isAllLetters(b) {
return "", 0, fmt.Errorf("列范围非法: %s(应形如 \"A:C\")", addr)
}
// isAllLetters 已保证 a/b 为非空纯字母,补 "1" 后 parseA1Cell 必然成功,无需再判错。
c1, _, _ := parseA1Cell(strings.ToUpper(a) + "1")
c2, _, _ := parseA1Cell(strings.ToUpper(b) + "1")
// 反序范围(如 "C:A")必须同时交换起始列,否则会静默改错目标:
// 只交换用于算长度的索引会得到 startIndex="C"/length=3,改到 C/D/E 而非 A/B/C。
// 行分支同样在交换后返回较小的 r1。
start = strings.ToUpper(a)
if c2 < c1 {
c1, c2 = c2, c1
start = strings.ToUpper(b)
}
return start, c2 - c1 + 1, nil
}
// parseCreateStyles 解析 --styles,校验结构;expectedNames 非空时校验数量/顺序/name 与 --sheets 一致。
func parseCreateStyles(stylesStr string, expectedNames []string) ([]sheetStyleOps, error) {
var payload any
if err := json.Unmarshal([]byte(stylesStr), &payload); err != nil {
return nil, fmt.Errorf("--styles JSON 解析失败: %w", err)
}
// 接受 {"styles":[...]} 或直接数组
var arrRaw []byte
switch v := payload.(type) {
case map[string]any:
inner, ok := v["styles"]
if !ok {
return nil, fmt.Errorf("--styles 对象形式必须含 styles 数组")
}
arrRaw, _ = json.Marshal(inner)
case []any:
arrRaw, _ = json.Marshal(v)
default:
return nil, fmt.Errorf("--styles 必须是 {\"styles\":[...]} 或 JSON 数组")
}
// 顶层键先按原始 map 查一遍:json.Unmarshal 只认 sheetStyleOps 的 tag,写成
// cellStyles 的那份样式会被静默丢掉,同一项里的 row_sizes 却照常生效 —— 用户拿到
// 的是一份「命令成功但样式不全」的表格,而 --styles 是建文档之后的非原子步骤。
var rawItems []map[string]any
if err := json.Unmarshal(arrRaw, &rawItems); err != nil {
return nil, fmt.Errorf("--styles 解析失败: %w", err)
}
for i, raw := range rawItems {
if err := rejectUnknownFields(raw, "--styles 单项", styleItemFields); err != nil {
return nil, fmt.Errorf("--styles[%d]: %w", i, err)
}
}
var items []sheetStyleOps
if err := json.Unmarshal(arrRaw, &items); err != nil {
return nil, fmt.Errorf("--styles 解析失败: %w", err)
}
if len(items) == 0 {
return nil, fmt.Errorf("--styles 不能为空数组")
}
for i, it := range items {
if it.isEmpty() {
return nil, fmt.Errorf("--styles[%d] 至少需要 cell_styles / row_sizes / col_sizes / cell_merges 之一", i)
}
}
if len(expectedNames) > 0 {
// 与 --sheets 搭配:长度/顺序/name 必须一一对应(同飞书规则)
if len(items) != len(expectedNames) {
return nil, fmt.Errorf("--styles 项数(%d)必须与 --sheets 子表数(%d)一致且顺序对应", len(items), len(expectedNames))
}
for i, it := range items {
if it.Name != expectedNames[i] {
return nil, fmt.Errorf("--styles[%d].name=%q 与 --sheets[%d].name=%q 不一致(需顺序对应)", i, it.Name, i, expectedNames[i])
}
}
} else if len(items) != 1 {
// 与 --values 搭配:只接受一项(其 name 忽略)
return nil, fmt.Errorf("--values 搭配 --styles 时只能有 1 项(当前 %d 项)", len(items))
}
return items, nil
}
// styleCall 是一次待执行的 MCP 调用(先规划、后执行,便于前置校验)。
type styleCall struct {
tool string
args map[string]any
}
// planStyleOps 把 styles 单项翻译为待执行的 MCP 调用序列;纯函数、不发请求,
// 所有结构/枚举校验都在此完成,便于在创建文档**之前**快速失败。
// 顺序:cell_styles → row_sizes → col_sizes → cell_merges(合并最后,避免干扰)。
func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error) {
var calls []styleCall
for i, item := range ops.CellStyles {
spec, rangeAddr, err := cellStyleItemToSpec(item)
if err != nil {
return nil, fmt.Errorf("cell_styles[%d]: %w", i, err)
}
rows, cols, err := parseA1Range(rangeAddr)
if err != nil {
return nil, fmt.Errorf("cell_styles[%d].range: %w", i, err)
}
cells, err := buildStyleCells(spec, rows, cols)
if err != nil {
return nil, fmt.Errorf("cell_styles[%d]: %w", i, err)
}
calls = append(calls, styleCall{"set_cell_range", map[string]any{
"nodeId": nodeID, "sheetId": sheetID, "rangeAddress": rangeAddr, "cells": cells,
}})
}
planSizes := func(list []map[string]any, isRow bool, label string) error {
for i, item := range list {
if err := rejectUnknownFields(item, label+" 项", sizeItemFields); err != nil {
return fmt.Errorf("%s[%d]: %w", label, i, err)
}
rangeAddr := pickStr(item, "range")
if rangeAddr == "" {
return fmt.Errorf("%s[%d] 缺少必填的 range", label, i)
}
// type 对齐飞书:pixel(需 size)/ standard(恢复默认尺寸)/ auto(按内容自适应,仅行支持)
typ := strings.ToLower(pickStr(item, "type"))
if typ == "" {
typ = "pixel"
}
dim := "COLUMNS"
if isRow {
dim = "ROWS"
}
args := map[string]any{
"nodeId": nodeID, "sheetId": sheetID, "dimension": dim, "sizeType": typ,
}
// type 枚举按维度区分(与飞书一致):row_sizes 有 auto,col_sizes 只有 pixel / standard
enumHint := "pixel / standard"
if isRow {
enumHint = "pixel / standard / auto"
}
switch {
case typ == "pixel":
size, ok, err := pickNum(item, "size")
if err != nil {
return fmt.Errorf("%s[%d].%w", label, i, err)
}
if !ok || size <= 0 {
return fmt.Errorf("%s[%d] type=pixel 时必须提供正整数 size", label, i)
}
args["pixelSize"] = size
case typ == "standard":
// 尺寸由服务端读取默认行高/列宽决定,无需 size。
// 给了 size 说明调用方预期不符,静默忽略会让人以为生效了。
_, ok, err := pickNum(item, "size")
if err != nil {
return fmt.Errorf("%s[%d].%w", label, i, err)
}
if ok {
return fmt.Errorf("%s[%d] type=standard 表示恢复默认尺寸,不能同时给 size;要指定固定像素请用 type=pixel", label, i)
}
case typ == "auto" && isRow:
// 行高按内容自适应,无需 size
_, ok, err := pickNum(item, "size")
if err != nil {
return fmt.Errorf("%s[%d].%w", label, i, err)
}
if ok {
return fmt.Errorf("%s[%d] type=auto 表示按内容自适应,不能同时给 size;要指定固定像素请用 type=pixel", label, i)
}
default:
return fmt.Errorf("%s[%d].type=%q 非法(合法值: %s)", label, i, typ, enumHint)
}
start, length, err := parseRowColRange(rangeAddr, isRow)
if err != nil {
return fmt.Errorf("%s[%d].range: %w", label, i, err)
}
args["startIndex"] = start
args["length"] = length
calls = append(calls, styleCall{"update_dimension", args})
}
return nil
}
if err := planSizes(ops.RowSizes, true, "row_sizes"); err != nil {
return nil, err
}
if err := planSizes(ops.ColSizes, false, "col_sizes"); err != nil {
return nil, err
}
for i, item := range ops.CellMerges {
if err := rejectUnknownFields(item, "cell_merges 项", mergeItemFields); err != nil {
return nil, fmt.Errorf("cell_merges[%d]: %w", i, err)
}
rangeAddr := pickStr(item, "range")
if rangeAddr == "" {
return nil, fmt.Errorf("cell_merges[%d] 缺少必填的 range", i)
}
// 严格解析合并区域地址,与 cell_styles 分支一致:planStyleOps 会在创建文档
// **之前**被干跑一次做前置校验,非法 range(如 "not-a-range")必须在此拦下,
// 否则会走到建文档→写数据→应用前序样式,直到最后 merge_cells 才被服务端
// 拒绝,留下无法回滚的部分完成文档。merge_cells 的 range 契约是 A1:B3 形态,
// parseA1Range 正好覆盖(并自动剥离 Sheet1! 前缀)。
if _, _, err := parseA1Range(rangeAddr); err != nil {
return nil, fmt.Errorf("cell_merges[%d].range: %w", i, err)
}
mergeType, err := feishuMergeType(pickStr(item, "merge_type", "mergeType"))
if err != nil {
return nil, fmt.Errorf("cell_merges[%d]: %w", i, err)
}
calls = append(calls, styleCall{"merge_cells", map[string]any{
"nodeId": nodeID, "sheetId": sheetID, "rangeAddress": rangeAddr,
"mergeType": mergeType,
}})
}
return calls, nil
}
// applyStyleOps 规划并顺序执行 styles 单项对应的 MCP 调用。
func applyStyleOps(ctx context.Context, nodeID, sheetID string, ops sheetStyleOps) error {
calls, err := planStyleOps(nodeID, sheetID, ops)
if err != nil {
return err
}
for _, c := range calls {
if err := callMCPToolSilent(ctx, c.tool, c.args); err != nil {
return fmt.Errorf("%s 应用失败: %w", c.tool, err)
}
}
return nil
}
// callMCPToolSilent 调用 MCP 工具但不打印结果(用于编排中的中间步骤)。
func callMCPToolSilent(ctx context.Context, tool string, args map[string]any) error {
_, err := callMCPToolReturnText(ctx, tool, args)
return err
}
// waitSheetWritable 等待新建文档进入可写状态。
// 新建表格后服务端仍在初始化,此时写入可能返回成功但数据不落盘,
// 因此先用 get_all_sheets 探活(带退避重试),确认文档已就绪再写数据。
func waitSheetWritable(ctx context.Context, nodeID string) error {
delays := []time.Duration{0, 500 * time.Millisecond, 1 * time.Second, 2 * time.Second, 3 * time.Second}
var lastErr error
for _, d := range delays {
if d > 0 {
// helperAfter 是测试时间缝(生产等价于 time.After);
// 与 wukong 一致,此处不响应 ctx 取消。
<-helperAfter(d)
}
if _, err := resolveFirstSheetID(ctx, nodeID); err == nil {
return nil
} else {
lastErr = err
}
}
return lastErr
}
// firstNonEmptyValuesCell 返回 values 里第一个非空单元格的 A1 地址(按行优先)。
// 调用方已保证矩阵含至少一个非空格,所以不会退化到 "A1" 兜底之外的情况。
func firstNonEmptyValuesCell(values [][]any) string {
for i, row := range values {
for j, cell := range row {
if cellToString(cell) != "" {
return fmt.Sprintf("%s%d", sheetColumnLetterFromZeroBased(j), i+1)
}
}
}
return "A1"
}
// valuesHaveContent 判断二维数据里是否有任何非空单元格。
// 全空矩阵(如 [["",""]])写不出任何内容,必须在建文档之前拒掉,
// 否则会白建一个空文档再由回读校验报成"写入未生效"。
func valuesHaveContent(values [][]any) bool {
for _, row := range values {
for _, cell := range row {
if cellToString(cell) != "" {
return true
}
}
}
return false
}
// verifyRangeNotEmpty 回读校验目标区域确实写入了数据(防"返回成功但未落盘")。
// 返回 nil 表示已确认非空;无法确认时返回错误说明。
func verifyRangeNotEmpty(ctx context.Context, nodeID, sheetID, rangeAddr string) error {
text, err := callMCPToolReturnText(ctx, "get_range_as_csv", map[string]any{
"nodeId": nodeID, "sheetId": sheetID, "range": rangeAddr,
})
if err != nil {
return fmt.Errorf("回读校验失败: %w", err)
}
data := unwrapSheetResult(text)
if data == nil {
return fmt.Errorf("回读校验失败:无法解析返回")
}
csvText, _ := data["csv"].(string)
// 去掉 [row=N] 前缀与分隔符后若无任何实质字符,视为未落盘
stripped := strings.NewReplacer(",", "", "\n", "", " ", "").Replace(
regexpRowPrefix.ReplaceAllString(csvText, ""))
if strings.TrimSpace(stripped) == "" {
return fmt.Errorf("数据未落盘(回读为空)")
}
return nil
}
// regexpRowPrefix 匹配 csv-get 的 [row=N] 行号前缀。
var regexpRowPrefix = regexp.MustCompile(`\[row=\d+\]\s*`)
// writeValuesToSheet 把二维数据转 CSV 后写入指定工作表(复用 set_range_from_csv,允许覆盖)。
func writeValuesToSheet(ctx context.Context, nodeID, sheetID string, values [][]any) error {
if len(values) == 0 {
return nil
}
return callMCPToolSilent(ctx, "set_range_from_csv", map[string]any{
"nodeId": nodeID,
"sheetId": sheetID,
"csv": valuesToCSV(values),
"startCell": "A1",
"allowOverwrite": true,
})
}
// valuesToCSV 把二维数据编码为 RFC4180 CSV 文本(每个单元格转字符串,nil 视为空)。
func valuesToCSV(values [][]any) string {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
for _, row := range values {
rec := make([]string, len(row))
for i, cell := range row {
rec[i] = cellToString(cell)
}
_ = w.Write(rec)
}
w.Flush()
return buf.String()
}
func cellToString(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case bool:
return strconv.FormatBool(t)
case json.Number:
// 原样输出字面量,避免任何浮点中转造成的精度损失
return t.String()
case float64:
// 整数不带小数点
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return fmt.Sprintf("%v", t)
}
}
// parseCreatedNodeID 从 create_workspace_sheet 响应提取 nodeId。
func parseCreatedNodeID(text string) (string, error) {
data := unwrapSheetResult(text)
if data == nil {
return "", fmt.Errorf("解析创建结果失败,响应: %s", text)
}
if nodeID, _ := data["nodeId"].(string); nodeID != "" {
return nodeID, nil
}
return "", fmt.Errorf("创建结果未返回 nodeId,响应: %s", text)
}
// resolveFirstSheetID 通过 get_all_sheets 获取第一个工作表的 sheetId。
func resolveFirstSheetID(ctx context.Context, nodeID string) (string, error) {
text, err := callMCPToolReturnText(ctx, "get_all_sheets", map[string]any{"nodeId": nodeID})
if err != nil {
return "", err
}
data := unwrapSheetResult(text)
if data == nil {
return "", fmt.Errorf("解析工作表列表失败,响应: %s", text)
}
sheets, _ := data["sheets"].([]any)
if len(sheets) == 0 {
return "", fmt.Errorf("表格中未找到任何工作表")
}
first, _ := sheets[0].(map[string]any)
if id, _ := first["sheetId"].(string); id != "" {
return id, nil
}
return "", fmt.Errorf("工作表列表未返回 sheetId,响应: %s", text)
}
// resolveSheetIDsByName 返回文档内 name→sheetId 映射,用于 table_put 之后按
// 工作表名逐一回读校验(table_put 会按 name 复用/新建工作表)。
func resolveSheetIDsByName(ctx context.Context, nodeID string) (map[string]string, error) {
text, err := callMCPToolReturnText(ctx, "get_all_sheets", map[string]any{"nodeId": nodeID})
if err != nil {
return nil, err
}
data := unwrapSheetResult(text)
if data == nil {
return nil, fmt.Errorf("解析工作表列表失败,响应: %s", text)
}
sheets, _ := data["sheets"].([]any)
m := make(map[string]string, len(sheets))
for _, s := range sheets {
sm, _ := s.(map[string]any)
name, _ := sm["name"].(string)
id, _ := sm["sheetId"].(string)
if name != "" && id != "" {
m[name] = id
}
}
return m, nil
}
// sheetSpecGrid 把 table_put 的一个 sheet spec 还原成写入的二维逻辑网格:
// 表头行(写表头时)在前,其后接 data 行。用于定位首个预期非空单元格。
// spec 已由 validateCreateSheetSpec 校验,columns 必为字符串数组、data 行必为数组。
func sheetSpecGrid(spec map[string]any) [][]any {
var grid [][]any
if cols, ok := spec["columns"].([]any); ok && len(cols) > 0 && specWritesHeader(spec) {
grid = append(grid, cols)
}
if data, ok := spec["data"].([]any); ok {
for _, r := range data {
if row, ok := r.([]any); ok {
grid = append(grid, row)
}
}
}
return grid
}
// firstNonEmptySheetSpecCell 返回该 sheet spec 首个预期非空单元格的 A1 地址。
// 尊重起始格偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入
// 的内容(例如 header=false 且没给 data),这类工作表本就应为空,调用方跳过回读,
// 避免把合法的空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell
// 一致:不能死盯 A1,首行/首列为空的合法数据不应被误报。
//
// 起始格键名只认 table_put 线上契约的 camelCase startCell —— snake_case 的
// start_cell 会被服务端 DTO 静默丢弃(写入实际落在 A1),探针若认它就会指到空白
// 处、把成功的写入报成「数据未落盘」;validateCreateSheetSpec 已在前置校验里拒掉。
func firstNonEmptySheetSpecCell(spec map[string]any) (string, bool) {
col0, row0 := 1, 1
if sc := normalizeSpecStartCell(pickStr(spec, "startCell")); sc != "" {
if c, r, err := parseA1Cell(sc); err == nil {
col0, row0 = c, r
}
}
// mode=append 时服务端按「已有数据的下一行」定位:本命令新建的工作表
// 都是空表,追加就从第 1 行开始,startCell 的行号被忽略(列号仍生效)。
if mode, _ := spec["mode"].(string); mode == "append" {
row0 = 1
}
for i, row := range sheetSpecGrid(spec) {
for j, cell := range row {
if cellToString(cell) != "" {
return fmt.Sprintf("%s%d", sheetColumnLetterFromZeroBased(col0-1+j), row0+i), true
}
}
}
return "", false
}
// unwrapSheetResult 解析 MCP 响应 JSON,自动剥离外层 result 包装。
func unwrapSheetResult(text string) map[string]any {
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil
}
if result, ok := data["result"].(map[string]any); ok {
return result
}
return data
}