diff --git a/CHANGELOG.md b/CHANGELOG.md index 4520108a..19ac76ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Changed -- **`sheet range set-style` 后端切换为 `set_cell_range`(破坏性 Schema 变更)** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容,无 flag 删除。 +- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。 - **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。 -- **`sheet export` / `sheet create` 声明为 composite(破坏性 Schema 变更)** — 两者都会按参数路由到多个工具(`--export-format csv` 走 `get_range_as_csv`;建表带数据串起建表→探活→写入→回读→样式),`mcp` 单一 ref 不准确。 - -### Changed - - **Doc import upload fallback** — `dws doc import` no longer fails on file formats outside the conversion whitelist (html, pdf, zip, extensionless, and any future format): it now hands the file to the document-space upload diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 9bf7b68c..94c839b7 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -580,6 +580,14 @@ func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error 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) @@ -814,13 +822,17 @@ func sheetSpecGrid(spec map[string]any) [][]any { } // firstNonEmptySheetSpecCell 返回该 sheet spec 首个预期非空单元格的 A1 地址。 -// 尊重 start_cell 偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入 +// 尊重起始格偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入 // 的内容(例如只给了 name),这类工作表本就应为空,调用方跳过回读,避免把合法的 // 空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell 一致:不能死盯 // A1,首行/首列为空的合法数据不应被误报。 +// +// 起始格键名以 table_put 线上契约的 camelCase startCell 为准(与本仓库 +// startCell / "startCell": "A1" 一致);同时兼容 snake_case start_cell,避免探针 +// 与实际写入位置错位导致「写成功却回读为空」的假阳性。 func firstNonEmptySheetSpecCell(spec map[string]any) (string, bool) { col0, row0 := 1, 1 - if sc, ok := spec["start_cell"].(string); ok && sc != "" { + if sc := pickStr(spec, "startCell", "start_cell"); sc != "" { if c, r, err := parseA1Cell(strings.ToUpper(sc)); err == nil { col0, row0 = c, r } diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index a0901f2a..41e3e0bd 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -250,6 +250,13 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_merges":[{"range":"A1:B1","merge_type":"invalid"}]}]}`}, `cell_merges[0]: merge_type 非法: "invalid"`, }, + { + // 非法合并区域地址同样只在最后 merge_cells 才被拒,会留下部分完成文档。 + // 必须在建文档之前严格解析拦下(与 cell_styles 的 range 校验一致)。 + "cell-merges-invalid-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_merges":[{"range":"not-a-range"}]}]}`}, + "cell_merges[0].range", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -396,7 +403,8 @@ func TestFirstNonEmptySheetSpecCell(t *testing.T) { {`{"name":"S","columns":["",""],"data":[["","x"]]}`, "B2", true}, // 首列表头空 {`{"name":"S","data":[[1]]}`, "A1", true}, // 无表头,纯 data {`{"name":"S","data":[5]}`, "A1", true}, // data 行是单值而非数组 - {`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell 偏移 + {`{"name":"S","columns":["id"],"data":[[1]],"startCell":"C3"}`, "C3", true}, // startCell(table_put 线上 camelCase) + {`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell(snake_case 兼容) {`{"name":"S"}`, "", false}, // 只有 name {`{"name":"S","columns":[],"data":[]}`, "", false}, // 空 } {