fix(sheet): validate merge range up front, fix startCell key, correct CHANGELOG

Three review P1s.

1. CHANGELOG no longer asserts a breaking Schema change this PR does not ship.
   sheet export / sheet create deliver main's mcp + interface_ref and
   schema-compatibility reports ok (0 changed fields), so the "declared as
   composite (breaking)" entry was false and is removed; the set-style entry
   drops the "breaking" framing (accepted as compatible by the reviewed
   mapping-exclusion carve-out); the duplicate ### Changed heading is merged.

2. firstNonEmptySheetSpecCell reads the start cell via
   pickStr(spec, "startCell", "start_cell"). Sheet specs are forwarded verbatim
   to table_put, whose wire fields are camelCase in this repo. The prior
   snake_case-only read meant a user passing the real startCell would have data
   written at the offset while the probe read A1 — a false "写入未生效" on a
   successful write. camelCase preferred, snake_case kept for tolerance.

3. planStyleOps now parses cell_merges range with parseA1Range, matching the
   cell_styles branch. planStyleOps is dry-run once before create_workspace_sheet
   as the up-front structural gate, so an invalid range like "not-a-range" is now
   rejected before any RPC instead of failing only at the final merge_cells call
   and leaving an unrollbackable partially-completed document. merge_cells' range
   contract is A1:B3-style, which parseA1Range covers (and it strips a Sheet1!
   prefix).

Tests: cell-merges-invalid-range added to TestSheetCreateValidatesBeforeCreating
Document (asserts calls == 0); TestFirstNonEmptySheetSpecCell covers both
startCell and start_cell. Changed-code coverage stays at 100%; targeted sheet
suites pass; CHANGELOG has a single Changed section with no false breaking claim.
This commit is contained in:
huwenjiao.hwj committed 2026-08-07 20:26:12 +08:00
1 parent dbecf23bc4
commit ecaa375be8
3 files changed
+24 -8

No files matched your search

+1 -5
View File
@@ -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
+14 -2
View File
@@ -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
}
@@ -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}, // 空
} {