diff --git a/CHANGELOG.md b/CHANGELOG.md index 5baa4e0..1c14ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.113 + +- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键) +- 元数据校验:排序列必须真实存在;引用隐藏列仅提示、不报错 + ## 1.0.112 - 新技能的名字不必再以平台或 `default` 结尾。没有抖音、小红书、亚马逊这类外部平台时,用动词加名词即可 diff --git a/SKILL.md b/SKILL.md index 67417ad..5115aa3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.112 +version: 1.0.113 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/references/ACTIONS.md b/references/ACTIONS.md index 480db06..67a48fc 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -776,6 +776,7 @@ HINT: - 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。 - 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。 - 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。 +- 误以为 `visible=0` 的列不能出现在 `default_sort_json`——隐藏只影响展示/筛选/搜索;ORDER BY 仍可用非敏感物理列(含隐藏主键)。 - 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。 ### Action 类型 × placements(常见示例,不是限制) diff --git a/references/SCHEMA.md b/references/SCHEMA.md index 17ba7ca..1b88213 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -180,11 +180,36 @@ JSON 数组(可多字段,按顺序): 规则: -- `column` 必须是该表真实列名;敏感列会被宿主忽略 +- `column` 必须是该表**真实物理列名**(存在于 SQLite 表);不存在则宿主忽略该段或技能校验报错 +- **可以**引用 `visible=0` 的列(含隐藏的主键 `id`)。`visible=0` 只表示界面不展示 / 不进默认 SELECT·筛选·搜索;**不表示不能 ORDER BY** +- 敏感列(宿主标为 sensitive)不得用于排序;技能侧勿把密钥类字段写进 `default_sort_json` - `direction` 仅 `asc` / `desc`(缺省按 `desc`) - 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认 - 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列 +与 `visible` 的分工(勿再踩坑): + +| 能力 | `visible=0` 是否仍可用 | +|------|------------------------| +| 表格列展示 / 「列」面板 | 否(硬隐藏) | +| 默认 SELECT、列筛选、顶栏搜索 | 否 | +| **`default_sort_json` / ORDER BY** | **是**(非敏感列) | +| 行主键定位(`$row.$pk` 等) | 是(主键即使隐藏仍应可排序、可定位;宿主查询层保留) | + +示例(编号硬隐藏,但仍按编号排序——合法): + +```python +upsert_table_metadata( + cursor, + table_name="period_rule_overrides", + display_name="本月默认金额", + readonly=1, + default_sort_json='[{"column":"period","direction":"desc"},{"column":"id","direction":"asc"}]', +) +# id 列元数据可见性: +# upsert_column_metadata(..., column_name="id", visible=0, ...) +``` + 示例(消息时间线): ```python @@ -277,6 +302,8 @@ upsert_column_metadata( - 只写英文物理名而不写中文 `display_name` - 让元数据引用不存在的表 - 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来) +- 误以为「列 `visible=0` 就不能写进 `default_sort_json`」——排序与展示已拆开;隐藏编号/主键仍可排序(须列真实存在且非敏感) +- 把不存在的列名写进 `default_sort_json`(技能校验应报错;宿主也会忽略无效段) ### `_jiangchang_columns` 字段级标准 diff --git a/scripts/db/display_metadata.py b/scripts/db/display_metadata.py index 3a91327..b90c778 100644 --- a/scripts/db/display_metadata.py +++ b/scripts/db/display_metadata.py @@ -103,6 +103,7 @@ def upsert_table_metadata( default_sort_json: 宿主数据管理默认排序,JSON 数组,例如 '[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]' 为空则宿主回退到 updated_at → created_at → 主键。 + 排序列须为真实物理列;**允许**引用 visible=0 的非敏感列(隐藏≠不能 ORDER BY)。 default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如 '["stat_type","period","stage"]' diff --git a/scripts/db/display_metadata_validator.py b/scripts/db/display_metadata_validator.py index 42db9d1..31415b5 100644 --- a/scripts/db/display_metadata_validator.py +++ b/scripts/db/display_metadata_validator.py @@ -279,6 +279,95 @@ def validate_filter_search_metadata( return [], warnings +def parse_default_sort_json(raw: object) -> tuple[list[dict[str, str]], list[str]]: + """解析 default_sort_json;返回 (specs, errors)。specs 项含 column / direction。""" + errors: list[str] = [] + if raw is None or str(raw).strip() == "": + return [], [] + text = str(raw).strip() + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return [], ["default_sort_json 不是合法 JSON"] + if not isinstance(parsed, list): + return [], ["default_sort_json 必须是 JSON 数组"] + out: list[dict[str, str]] = [] + for index, item in enumerate(parsed): + if not isinstance(item, dict): + errors.append(f"default_sort_json[{index}] 必须是对象") + continue + column = str( + item.get("column") + or item.get("columnName") + or item.get("column_name") + or "" + ).strip() + direction_raw = str(item.get("direction") or "desc").strip().lower() + if not column: + errors.append(f"default_sort_json[{index}] 缺少 column") + continue + if direction_raw not in {"asc", "desc"}: + errors.append(f"default_sort_json[{index}].direction 只能是 asc 或 desc") + continue + out.append({"column": column, "direction": direction_raw}) + return out, errors + + +def validate_default_sort_metadata( + cursor, + table_name: str, + *, + physical_columns: list[str] | None = None, +) -> tuple[list[str], list[str]]: + """校验表级 default_sort_json:列须真实存在;允许 visible=0;不存在则报错。 + + 契约:visible=0 只藏界面,ORDER BY 仍可用非敏感物理列(含隐藏主键)。 + """ + errors: list[str] = [] + warnings: list[str] = [] + table_cols = { + str(row[1]) for row in cursor.execute("PRAGMA table_info(_jiangchang_tables)").fetchall() + } + if "default_sort_json" not in table_cols: + return [], [] + cursor.execute( + "SELECT default_sort_json FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cursor.fetchone() + if not row: + return [], [] + specs, parse_errors = parse_default_sort_json(row[0]) + for msg in parse_errors: + errors.append(f"{table_name}: {msg}") + if parse_errors: + return errors, warnings + + physical = set(physical_columns or list_physical_column_names(cursor, table_name)) + for spec in specs: + column = spec["column"] + if column not in physical: + errors.append( + f"{table_name}: default_sort_json 引用了不存在的列 {column!r}" + ) + continue + # visible=0 合法:不报错。仅提示契约,避免作者误删排序列。 + cursor.execute( + """ + SELECT visible FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column), + ) + meta = cursor.fetchone() + if meta is not None and meta[0] is not None and int(meta[0]) == 0: + warnings.append( + f"{table_name}.{column}: default_sort_json 引用了 visible=0 列" + "(合法:隐藏列仍可 ORDER BY;见 references/SCHEMA.md)" + ) + return errors, warnings + + def validate_column_permissions( cursor, table_name: str, @@ -558,6 +647,14 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b _, filter_warnings = validate_filter_search_metadata(cur, table_name) report.warnings.extend(filter_warnings) + sort_errors, sort_warnings = validate_default_sort_metadata( + cur, + table_name, + physical_columns=physical_columns, + ) + report.errors.extend(sort_errors) + report.warnings.extend(sort_warnings) + cur.execute("SELECT table_name, column_name FROM _jiangchang_columns") for table_name, column_name in cur.fetchall(): if table_name in METADATA_TABLES: diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 5427b29..2b10feb 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.112" +SKILL_VERSION = "1.0.113" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py index c4d97dd..81fb2ae 100644 --- a/tests/test_display_metadata.py +++ b/tests/test_display_metadata.py @@ -595,6 +595,53 @@ metadata: self.assertIn("readonly = 1", text) self.assertIn("seed_column_metadata_batch", text) self.assertIn("valid_column_names", text) + self.assertIn("可以**引用 `visible=0` 的列", text) + self.assertIn("default_sort_json` / ORDER BY", text) + + def test_default_sort_json_allows_hidden_column_and_rejects_missing(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + seed_column_metadata_batch, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_default_sort_metadata + + cur = conn.cursor() + init_display_metadata_tables(cur) + cur.execute( + "CREATE TABLE demo_sort (id INTEGER PRIMARY KEY, title TEXT NOT NULL)" + ) + upsert_table_metadata( + cur, + table_name="demo_sort", + display_name="排序示例", + default_sort_json='[{"column":"title","direction":"asc"},{"column":"id","direction":"desc"}]', + ) + seed_column_metadata_batch( + cur, + table_name="demo_sort", + columns=( + {"column_name": "id", "display_name": "编号", "visible": 0, "editable": 0}, + {"column_name": "title", "display_name": "标题", "visible": 1, "editable": 1}, + ), + valid_column_names=["id", "title"], + ) + errors, warnings = validate_default_sort_metadata(cur, "demo_sort") + self.assertEqual(errors, []) + self.assertTrue(any("visible=0" in w and "id" in w for w in warnings)) + + upsert_table_metadata( + cur, + table_name="demo_sort", + display_name="排序示例", + default_sort_json='[{"column":"missing_col","direction":"asc"}]', + ) + errors, _ = validate_default_sort_metadata(cur, "demo_sort") + self.assertTrue(any("missing_col" in e for e in errors)) + finally: + conn.close() def test_default_visible_columns_json_upsert_and_migrate(self) -> None: conn = sqlite3.connect(":memory:")