Compare commits

...

1 Commits

Author SHA1 Message Date
a8403ce9b4 Release v1.0.113: document default_sort may use hidden columns
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-09-23 10:14:20 +08:00
8 changed files with 181 additions and 3 deletions

View File

@@ -9,6 +9,11 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.113
- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键)
- 元数据校验:排序列必须真实存在;引用隐藏列仅提示、不报错
## 1.0.112
- 新技能的名字不必再以平台或 `default` 结尾。没有抖音、小红书、亚马逊这类外部平台时,用动词加名词即可

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.112
version: 1.0.113
author: 深圳匠厂科技有限公司
metadata:
openclaw:

View File

@@ -776,6 +776,7 @@ HINT: <next step>
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`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(常见示例,不是限制)

View File

@@ -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` 字段级标准

View File

@@ -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"]'

View File

@@ -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:

View File

@@ -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"

View File

@@ -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:")