370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""数据管理展示元数据校验(模板开发自检,不依赖宿主)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
from dataclasses import dataclass, field
|
||
|
||
from db.display_metadata import (
|
||
DATETIME_UNIX_SECONDS,
|
||
METADATA_TABLES,
|
||
STANDARD_TIMESTAMP_COLUMNS,
|
||
TASK_LOGS_COLUMN_METADATA,
|
||
TASK_LOGS_TABLE,
|
||
iter_user_tables,
|
||
list_physical_column_names,
|
||
)
|
||
from db.timestamp_columns import has_updated_at_trigger
|
||
from util.constants import SKILL_SLUG
|
||
|
||
_TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"})
|
||
_ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
|
||
re.compile(r"^my\s+skill$", re.I),
|
||
re.compile(r"^your[-_\s]?skill", re.I),
|
||
re.compile(r"^skill[-_\s]?template$", re.I),
|
||
re.compile(r"^[a-z0-9_-]+$"),
|
||
)
|
||
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||
|
||
|
||
@dataclass
|
||
class ValidationReport:
|
||
errors: list[str] = field(default_factory=list)
|
||
warnings: list[str] = field(default_factory=list)
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
return not self.errors
|
||
|
||
|
||
def _has_cjk(text: str) -> bool:
|
||
return bool(_CJK_RE.search(text))
|
||
|
||
|
||
def _looks_like_raw_physical_name(display_name: str, physical_name: str) -> bool:
|
||
if not display_name.strip():
|
||
return True
|
||
if display_name.strip().lower() == physical_name.strip().lower():
|
||
return True
|
||
compact = display_name.replace(" ", "").replace("_", "").lower()
|
||
if compact == physical_name.replace("_", "").lower():
|
||
return True
|
||
return False
|
||
|
||
|
||
def _parse_frontmatter_field(text: str, field_name: str) -> str:
|
||
parts = text.split("---", 2)
|
||
if len(parts) < 2:
|
||
return ""
|
||
for line in parts[1].splitlines():
|
||
match = re.match(rf"^{re.escape(field_name)}:\s*(.+)\s*$", line.strip())
|
||
if match:
|
||
return match.group(1).strip().strip("\"'")
|
||
return ""
|
||
|
||
|
||
def _parse_openclaw_slug(text: str) -> str:
|
||
parts = text.split("---", 2)
|
||
if len(parts) < 2:
|
||
return ""
|
||
lines = parts[1].splitlines()
|
||
for index, line in enumerate(lines):
|
||
if re.match(r"^\s+openclaw:\s*$", line):
|
||
for inner in lines[index + 1 :]:
|
||
if inner.strip() and inner[0] not in (" ", "\t"):
|
||
break
|
||
match = re.match(r"^(\s+)slug:\s*(.+)\s*$", inner)
|
||
if match:
|
||
return match.group(2).strip().strip("\"'")
|
||
return ""
|
||
|
||
|
||
def parse_skill_frontmatter(skill_root: str) -> dict[str, str]:
|
||
path = os.path.join(skill_root, "SKILL.md")
|
||
with open(path, encoding="utf-8") as f:
|
||
text = f.read()
|
||
return {
|
||
"name": _parse_frontmatter_field(text, "name"),
|
||
"slug": _parse_openclaw_slug(text),
|
||
}
|
||
|
||
|
||
def validate_skill_frontmatter(skill_root: str, *, allow_template_placeholders: bool = True) -> ValidationReport:
|
||
report = ValidationReport()
|
||
data = parse_skill_frontmatter(skill_root)
|
||
name = str(data.get("name") or "").strip()
|
||
slug = str(data.get("slug") or "").strip()
|
||
|
||
if not name:
|
||
report.errors.append("SKILL.md frontmatter.name 缺失")
|
||
elif not _has_cjk(name):
|
||
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
|
||
report.warnings.append("SKILL.md frontmatter.name 建议使用清晰的中文业务名称(模板占位可暂保留)")
|
||
else:
|
||
report.errors.append("SKILL.md frontmatter.name 应使用用户可见的中文业务名称")
|
||
else:
|
||
for pattern in _ENGLISH_PLACEHOLDER_NAME_PATTERNS:
|
||
if pattern.fullmatch(name):
|
||
if allow_template_placeholders and slug in _TEMPLATE_PLACEHOLDER_SLUGS:
|
||
report.warnings.append(f"SKILL.md frontmatter.name 仍为占位风格:{name!r}")
|
||
else:
|
||
report.errors.append(f"SKILL.md frontmatter.name 不应为英文占位名:{name!r}")
|
||
break
|
||
|
||
if not slug:
|
||
report.errors.append("SKILL.md metadata.openclaw.slug 缺失")
|
||
elif not _KEBAB_SLUG.fullmatch(slug):
|
||
report.errors.append(f"metadata.openclaw.slug 必须为 kebab-case:{slug!r}")
|
||
elif slug in _TEMPLATE_PLACEHOLDER_SLUGS and not allow_template_placeholders:
|
||
report.errors.append(f"metadata.openclaw.slug 仍为模板占位:{slug!r}")
|
||
|
||
constants_slug = SKILL_SLUG.strip()
|
||
if slug and constants_slug and slug != constants_slug:
|
||
report.warnings.append(
|
||
f"SKILL.md slug ({slug!r}) 与 util.constants.SKILL_SLUG ({constants_slug!r}) 不一致"
|
||
)
|
||
|
||
return report
|
||
|
||
|
||
def _quote_identifier(name: str) -> str:
|
||
return '"' + name.replace('"', '""') + '"'
|
||
|
||
|
||
def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]:
|
||
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
|
||
info: dict[str, dict[str, object]] = {}
|
||
for row in cursor.fetchall():
|
||
info[str(row[1])] = {
|
||
"cid": int(row[0]),
|
||
"name": str(row[1]),
|
||
"type": str(row[2] or ""),
|
||
"notnull": int(row[3] or 0),
|
||
"dflt_value": row[4],
|
||
"pk": int(row[5] or 0),
|
||
}
|
||
return info
|
||
|
||
|
||
def _default_uses_unixepoch(default_value: object) -> bool:
|
||
if default_value is None:
|
||
return False
|
||
text = str(default_value).strip().lower()
|
||
return "unixepoch" in text
|
||
|
||
|
||
def _expected_timestamp_display_name(column_name: str) -> str:
|
||
if column_name == "created_at":
|
||
return "创建时间"
|
||
if column_name == "updated_at":
|
||
return "更新时间"
|
||
return ""
|
||
|
||
|
||
def validate_standard_timestamp_columns(
|
||
cursor,
|
||
table_name: str,
|
||
*,
|
||
physical_columns: list[str] | None = None,
|
||
) -> tuple[list[str], list[str]]:
|
||
errors: list[str] = []
|
||
warnings: list[str] = []
|
||
ordered = physical_columns or list_physical_column_names(cursor, table_name)
|
||
column_info = _table_column_info(cursor, table_name)
|
||
|
||
for column_name in STANDARD_TIMESTAMP_COLUMNS:
|
||
if column_name not in ordered:
|
||
continue
|
||
|
||
physical = column_info.get(column_name)
|
||
if not physical:
|
||
errors.append(f"{table_name}.{column_name} 缺少物理字段定义")
|
||
continue
|
||
|
||
if "INT" not in str(physical["type"]).upper():
|
||
errors.append(
|
||
f"{table_name}.{column_name} 字段类型应为 INTEGER(Unix 秒级时间戳),当前为 {physical['type']!r}"
|
||
)
|
||
|
||
if column_name == "created_at" and not _default_uses_unixepoch(physical["dflt_value"]):
|
||
errors.append(
|
||
f"{table_name}.created_at 应设置数据库默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}"
|
||
)
|
||
if column_name == "updated_at" and not _default_uses_unixepoch(physical["dflt_value"]):
|
||
errors.append(
|
||
f"{table_name}.updated_at 应设置数据库初始默认值 DEFAULT (unixepoch()),当前默认值为 {physical['dflt_value']!r}"
|
||
)
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT display_name, display_type, editable, searchable, display_order
|
||
FROM _jiangchang_columns
|
||
WHERE table_name = ? AND column_name = ?
|
||
""",
|
||
(table_name, column_name),
|
||
)
|
||
meta_row = cursor.fetchone()
|
||
if not meta_row:
|
||
errors.append(f"{table_name}.{column_name} 缺少 _jiangchang_columns 元数据")
|
||
continue
|
||
|
||
display_name, display_type, editable, searchable, display_order = meta_row
|
||
expected_name = _expected_timestamp_display_name(column_name)
|
||
if str(display_name or "").strip() != expected_name:
|
||
errors.append(
|
||
f"{table_name}.{column_name} 的 display_name 应为 {expected_name!r},当前为 {display_name!r}"
|
||
)
|
||
if str(display_type or "").strip() != DATETIME_UNIX_SECONDS:
|
||
errors.append(
|
||
f"{table_name}.{column_name} 的 display_type 应为 {DATETIME_UNIX_SECONDS!r},当前为 {display_type!r}"
|
||
)
|
||
if editable is None or int(editable) != 0:
|
||
errors.append(f"{table_name}.{column_name} 的 editable 应为 0(系统时间字段不可编辑)")
|
||
if display_order is not None:
|
||
errors.append(f"{table_name}.{column_name} 的 display_order 应为 NULL")
|
||
if searchable is not None and int(searchable) != 0:
|
||
warnings.append(f"{table_name}.{column_name} 的时间字段通常不必开启 searchable")
|
||
|
||
if "created_at" in ordered and "updated_at" in ordered:
|
||
if ordered.index("updated_at") < ordered.index("created_at"):
|
||
warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前")
|
||
|
||
if "updated_at" in ordered and not has_updated_at_trigger(cursor, table_name):
|
||
errors.append(
|
||
f"{table_name}.updated_at 缺少自动维护 trigger(期望 {table_name}_set_updated_at)"
|
||
)
|
||
|
||
return errors, warnings
|
||
|
||
|
||
def column_order_warnings(table_name: str, ordered_names: list[str]) -> list[str]:
|
||
warnings: list[str] = []
|
||
if "id" in ordered_names and ordered_names.index("id") != 0:
|
||
warnings.append(f"{table_name}: 字段 id 通常应定义在 CREATE TABLE 的第一列")
|
||
if "created_at" in ordered_names:
|
||
idx = ordered_names.index("created_at")
|
||
trailing = ordered_names[idx + 1 :]
|
||
if trailing and trailing != ["updated_at"]:
|
||
warnings.append(
|
||
f"{table_name}: created_at 之后通常只应跟 updated_at,不应再放置其他业务字段"
|
||
)
|
||
if "updated_at" in ordered_names and "created_at" in ordered_names:
|
||
if ordered_names.index("updated_at") < ordered_names.index("created_at"):
|
||
warnings.append(f"{table_name}: updated_at 不应出现在 created_at 之前")
|
||
return warnings
|
||
|
||
|
||
def _table_exists(cursor, table_name: str) -> bool:
|
||
cursor.execute(
|
||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
||
(table_name,),
|
||
)
|
||
return cursor.fetchone() is not None
|
||
|
||
|
||
def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: bool = True) -> ValidationReport:
|
||
report = ValidationReport()
|
||
cur = conn.cursor()
|
||
|
||
has_tables_meta = _table_exists(cur, "_jiangchang_tables")
|
||
has_columns_meta = _table_exists(cur, "_jiangchang_columns")
|
||
if not has_tables_meta:
|
||
report.errors.append("缺少数据管理元数据表 _jiangchang_tables")
|
||
if not has_columns_meta:
|
||
report.errors.append("缺少数据管理元数据表 _jiangchang_columns")
|
||
if not has_tables_meta or not has_columns_meta:
|
||
return report
|
||
|
||
for table_name in iter_user_tables(cur):
|
||
cur.execute(
|
||
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
|
||
(table_name,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row or not str(row[0] or "").strip():
|
||
report.errors.append(f"业务表 {table_name!r} 缺少 _jiangchang_tables.display_name")
|
||
elif not _has_cjk(str(row[0])):
|
||
report.errors.append(f"业务表 {table_name!r} 的 display_name 应使用中文业务名称")
|
||
|
||
physical_columns = list_physical_column_names(cur, table_name)
|
||
if check_column_order:
|
||
report.warnings.extend(column_order_warnings(table_name, physical_columns))
|
||
|
||
for column_name in physical_columns:
|
||
cur.execute(
|
||
"""
|
||
SELECT display_name, visible
|
||
FROM _jiangchang_columns
|
||
WHERE table_name = ? AND column_name = ?
|
||
""",
|
||
(table_name, column_name),
|
||
)
|
||
col_row = cur.fetchone()
|
||
if not col_row:
|
||
report.errors.append(
|
||
f"字段 {table_name}.{column_name} 缺少 _jiangchang_columns 元数据"
|
||
)
|
||
continue
|
||
display_name = str(col_row[0] or "").strip()
|
||
visible = col_row[1] is None or int(col_row[1]) != 0
|
||
if visible and not display_name:
|
||
report.errors.append(f"字段 {table_name}.{column_name} 的 display_name 为空")
|
||
elif visible and _looks_like_raw_physical_name(display_name, column_name):
|
||
report.errors.append(
|
||
f"字段 {table_name}.{column_name} 的 display_name 不应直接等于英文物理名:{display_name!r}"
|
||
)
|
||
elif visible and not _has_cjk(display_name):
|
||
report.errors.append(
|
||
f"字段 {table_name}.{column_name} 的 display_name 应使用中文:{display_name!r}"
|
||
)
|
||
|
||
ts_errors, ts_warnings = validate_standard_timestamp_columns(
|
||
cur,
|
||
table_name,
|
||
physical_columns=physical_columns,
|
||
)
|
||
report.errors.extend(ts_errors)
|
||
report.warnings.extend(ts_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:
|
||
continue
|
||
cur.execute(
|
||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
|
||
(table_name,),
|
||
)
|
||
if not cur.fetchone():
|
||
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
|
||
continue
|
||
physical = list_physical_column_names(cur, str(table_name))
|
||
if str(column_name) not in physical:
|
||
report.errors.append(f"元数据引用了不存在的字段:{table_name}.{column_name}")
|
||
|
||
cur.execute("SELECT table_name FROM _jiangchang_tables")
|
||
for (table_name,) in cur.fetchall():
|
||
if table_name in METADATA_TABLES:
|
||
continue
|
||
cur.execute(
|
||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?",
|
||
(table_name,),
|
||
)
|
||
if not cur.fetchone():
|
||
report.errors.append(f"元数据引用了不存在的表:{table_name!r}")
|
||
|
||
return report
|
||
|
||
|
||
def validate_template_database(conn: sqlite3.Connection) -> ValidationReport:
|
||
report = validate_display_metadata(conn)
|
||
physical = list_physical_column_names(conn.cursor(), TASK_LOGS_TABLE)
|
||
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
|
||
if physical != expected:
|
||
report.errors.append(
|
||
f"task_logs 物理字段顺序应为 {expected},当前为 {physical}"
|
||
)
|
||
return report
|