feat: add Chinese display metadata for data management UI
All checks were successful
技能自动化发布 / release (push) Successful in 8s
All checks were successful
技能自动化发布 / release (push) Successful in 8s
This commit is contained in:
2
SKILL.md
2
SKILL.md
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: 账号与凭据管理
|
name: 账号与凭据管理
|
||||||
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
|
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
|
||||||
version: 2.0.11
|
version: 2.0.12
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sqlite3
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from db.display_metadata import init_display_metadata
|
||||||
from db.schema import SCHEMA_VERSION, ensure_schema
|
from db.schema import SCHEMA_VERSION, ensure_schema
|
||||||
from util.constants import SKILL_SLUG
|
from util.constants import SKILL_SLUG
|
||||||
from util.logging_config import get_skill_logger
|
from util.logging_config import get_skill_logger
|
||||||
@@ -85,6 +86,7 @@ def init_db() -> None:
|
|||||||
ensure_schema(conn)
|
ensure_schema(conn)
|
||||||
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
init_display_metadata(cur)
|
||||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|||||||
421
scripts/db/display_metadata.py
Normal file
421
scripts/db/display_metadata.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""匠厂数据管理展示元数据(_jiangchang_tables / _jiangchang_columns)。
|
||||||
|
|
||||||
|
SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。
|
||||||
|
字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
|
||||||
|
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
|
||||||
|
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
|
||||||
|
|
||||||
|
PLATFORMS_TABLE = "platforms"
|
||||||
|
ACCOUNTS_TABLE = "accounts"
|
||||||
|
CREDENTIALS_TABLE = "credentials"
|
||||||
|
ACCOUNT_LEASES_TABLE = "account_leases"
|
||||||
|
|
||||||
|
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
|
||||||
|
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
|
||||||
|
|
||||||
|
USER_BUSINESS_TABLES = (
|
||||||
|
PLATFORMS_TABLE,
|
||||||
|
ACCOUNTS_TABLE,
|
||||||
|
CREDENTIALS_TABLE,
|
||||||
|
ACCOUNT_LEASES_TABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_JIANGCHANG_TABLES_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS _jiangchang_tables (
|
||||||
|
table_name TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
sort_order INTEGER,
|
||||||
|
visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
readonly INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_JIANGCHANG_COLUMNS_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS _jiangchang_columns (
|
||||||
|
table_name TEXT NOT NULL,
|
||||||
|
column_name TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
display_order INTEGER,
|
||||||
|
visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
searchable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
editable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
display_type TEXT,
|
||||||
|
options_json TEXT,
|
||||||
|
PRIMARY KEY (table_name, column_name)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _col(
|
||||||
|
column_name: str,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
visible: int = 1,
|
||||||
|
searchable: int = 1,
|
||||||
|
editable: int = 1,
|
||||||
|
display_type: str | None = None,
|
||||||
|
options_json: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"column_name": column_name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"description": description,
|
||||||
|
"visible": visible,
|
||||||
|
"searchable": searchable,
|
||||||
|
"editable": editable,
|
||||||
|
"display_type": display_type,
|
||||||
|
"options_json": options_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(column_name: str, display_name: str, description: str) -> dict[str, object]:
|
||||||
|
return _col(
|
||||||
|
column_name,
|
||||||
|
display_name,
|
||||||
|
description=description,
|
||||||
|
searchable=0,
|
||||||
|
editable=0,
|
||||||
|
display_type=DATETIME_UNIX_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORMS_TABLE_METADATA = {
|
||||||
|
"table_name": PLATFORMS_TABLE,
|
||||||
|
"display_name": "平台信息",
|
||||||
|
"description": "已注册平台(物流、LLM、内容等)及默认登录策略",
|
||||||
|
"sort_order": 1,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
PLATFORMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("platform_key", "平台键", description="平台唯一标识(英文 kebab-case)", editable=0),
|
||||||
|
_col("display_name", "展示名称", description="平台在界面上的显示名称"),
|
||||||
|
_col("domain", "业务域", description="logistics / llm / content / dev / sim 等"),
|
||||||
|
_col("provider_code", "供应商代码", description="供应商或平台代码,可空"),
|
||||||
|
_col("default_url", "默认链接", description="平台默认入口 URL"),
|
||||||
|
_col("aliases_json", "别名列表", description="平台别名 JSON 数组", editable=0),
|
||||||
|
_col("capabilities_json", "能力声明", description='能力 JSON,如 {"web":true,"api_key":false}', editable=0),
|
||||||
|
_col("enabled", "是否启用", description="1=启用,0=停用"),
|
||||||
|
_col("default_auth_strategy", "默认登录策略", description="新建账号时的默认 auth_strategy"),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNTS_TABLE_METADATA = {
|
||||||
|
"table_name": ACCOUNTS_TABLE,
|
||||||
|
"display_name": "账号信息",
|
||||||
|
"description": "网页/RPA 账号身份与浏览器 Profile 路径",
|
||||||
|
"sort_order": 2,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("id", "编号", description="账号唯一编号", editable=0),
|
||||||
|
_col("platform_key", "平台", description="所属平台键"),
|
||||||
|
_col("account_label", "账号名称", description="人可读的账号名称"),
|
||||||
|
_col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"),
|
||||||
|
_col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"),
|
||||||
|
_col("tenant_id", "租户编号", description="租户或客户标识,可空"),
|
||||||
|
_col("environment", "环境", description="production / simulator / staging 等"),
|
||||||
|
_col("role", "角色", description="booking / tracking / api / default 等"),
|
||||||
|
_col("provider_code", "供应商代码", description="冗余供应商码,便于查询"),
|
||||||
|
_col("profile_dir", "浏览器配置目录", description="Playwright 持久化 Profile 路径", editable=0),
|
||||||
|
_col("url", "入口链接", description="账号默认入口 URL"),
|
||||||
|
_col("status", "状态", description="active / disabled / needs_review 等"),
|
||||||
|
_col("session_status", "会话状态", description="unknown / likely_valid / needs_login 等"),
|
||||||
|
_ts("last_used_at", "最后使用时间", "最近一次被业务技能使用的时间"),
|
||||||
|
_ts("last_login_check_at", "最后登录检查时间", "最近一次登录状态检查时间"),
|
||||||
|
_col("last_error_code", "最近错误码", description="最近一次错误代码", editable=0),
|
||||||
|
_col("last_error_message", "最近错误信息", description="最近一次错误说明", editable=0),
|
||||||
|
_col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0),
|
||||||
|
_col("auth_strategy", "登录方式", description="九种登录策略之一"),
|
||||||
|
_col("session_persistent", "持久会话", description="是否需要持久化浏览器会话(0/1)"),
|
||||||
|
_col("device_fingerprint", "设备指纹", description="device_bound 策略专用,可空"),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
CREDENTIALS_TABLE_METADATA = {
|
||||||
|
"table_name": CREDENTIALS_TABLE,
|
||||||
|
"display_name": "凭证信息",
|
||||||
|
"description": "API Key、Token、加密密码等凭据元数据(不含明文)",
|
||||||
|
"sort_order": 3,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("id", "编号", description="凭据唯一编号", editable=0),
|
||||||
|
_col("account_id", "账号编号", description="关联的 accounts.id,可空"),
|
||||||
|
_col("platform_key", "平台", description="所属平台键"),
|
||||||
|
_col("credential_label", "凭据名称", description="人可读的凭据名称"),
|
||||||
|
_col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"),
|
||||||
|
_col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0),
|
||||||
|
_col("secret_ref", "密钥引用", description="环境变量名或凭据管理器 target", editable=0, searchable=0),
|
||||||
|
_col("secret_mask", "密钥掩码", description="打码后的 secret 展示", editable=0, searchable=0),
|
||||||
|
_col(
|
||||||
|
"secret_ciphertext",
|
||||||
|
"密钥密文",
|
||||||
|
description="local_encrypted 时的 Fernet 密文,不可手工编辑",
|
||||||
|
editable=0,
|
||||||
|
searchable=0,
|
||||||
|
),
|
||||||
|
_ts("expires_at", "过期时间", "凭据过期时间,可空"),
|
||||||
|
_col("status", "状态", description="active / disabled / expired 等"),
|
||||||
|
_ts("last_used_at", "最后使用时间", "最近一次使用时间"),
|
||||||
|
_col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_LEASES_TABLE_METADATA = {
|
||||||
|
"table_name": ACCOUNT_LEASES_TABLE,
|
||||||
|
"display_name": "账号租约",
|
||||||
|
"description": "RPA 并发租约,防止多任务同时使用同一浏览器 Profile",
|
||||||
|
"sort_order": 4,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCOUNT_LEASES_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("id", "编号", description="租约唯一编号", editable=0),
|
||||||
|
_col("account_id", "账号编号", description="关联的 accounts.id", editable=0),
|
||||||
|
_col("lease_token", "租约令牌", description="用于 release 的随机 token", editable=0, searchable=1),
|
||||||
|
_col("holder", "持有者", description="持有租约的业务技能或任务标识", editable=0),
|
||||||
|
_col("purpose", "用途", description="租约用途说明", editable=0),
|
||||||
|
_ts("expires_at", "过期时间", "租约过期时间"),
|
||||||
|
_ts("heartbeat_at", "心跳时间", "最近一次心跳时间"),
|
||||||
|
_ts("released_at", "释放时间", "主动释放时间,可空"),
|
||||||
|
_col("status", "状态", description="active / released / expired", editable=0),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_TABLE_SEEDS: tuple[tuple[dict[str, object], tuple[dict[str, object], ...]], ...] = (
|
||||||
|
(PLATFORMS_TABLE_METADATA, PLATFORMS_COLUMN_METADATA),
|
||||||
|
(ACCOUNTS_TABLE_METADATA, ACCOUNTS_COLUMN_METADATA),
|
||||||
|
(CREDENTIALS_TABLE_METADATA, CREDENTIALS_COLUMN_METADATA),
|
||||||
|
(ACCOUNT_LEASES_TABLE_METADATA, ACCOUNT_LEASES_COLUMN_METADATA),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_display_metadata_tables(cursor) -> None:
|
||||||
|
cursor.execute(_JIANGCHANG_TABLES_DDL)
|
||||||
|
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_table_metadata(
|
||||||
|
cursor,
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
sort_order: int | None = None,
|
||||||
|
visible: int = 1,
|
||||||
|
readonly: int = 0,
|
||||||
|
) -> None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO _jiangchang_tables (
|
||||||
|
table_name, display_name, description, sort_order, visible, readonly
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(table_name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
sort_order = excluded.sort_order,
|
||||||
|
visible = excluded.visible,
|
||||||
|
readonly = excluded.readonly
|
||||||
|
""",
|
||||||
|
(table_name, display_name, description, sort_order, visible, readonly),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_column_metadata(
|
||||||
|
cursor,
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
column_name: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
visible: int = 1,
|
||||||
|
searchable: int = 1,
|
||||||
|
editable: int = 1,
|
||||||
|
display_type: str | None = None,
|
||||||
|
options_json: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO _jiangchang_columns (
|
||||||
|
table_name, column_name, display_name, description,
|
||||||
|
display_order, visible, searchable, editable, display_type, options_json
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(table_name, column_name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
display_order = NULL,
|
||||||
|
visible = excluded.visible,
|
||||||
|
searchable = excluded.searchable,
|
||||||
|
editable = excluded.editable,
|
||||||
|
display_type = excluded.display_type,
|
||||||
|
options_json = excluded.options_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
visible,
|
||||||
|
searchable,
|
||||||
|
editable,
|
||||||
|
display_type,
|
||||||
|
options_json,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_column_metadata_batch(
|
||||||
|
cursor,
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
columns: Iterable[dict[str, object]],
|
||||||
|
valid_column_names: Iterable[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
for column in columns:
|
||||||
|
upsert_column_metadata(
|
||||||
|
cursor,
|
||||||
|
table_name=table_name,
|
||||||
|
column_name=str(column["column_name"]),
|
||||||
|
display_name=str(column["display_name"]),
|
||||||
|
description=str(column["description"]) if column.get("description") else None,
|
||||||
|
visible=int(column.get("visible", 1)),
|
||||||
|
searchable=int(column.get("searchable", 1)),
|
||||||
|
editable=int(column.get("editable", 1)),
|
||||||
|
display_type=str(column["display_type"]) if column.get("display_type") else None,
|
||||||
|
options_json=str(column["options_json"]) if column.get("options_json") else None,
|
||||||
|
)
|
||||||
|
if valid_column_names is not None:
|
||||||
|
prune_stale_column_metadata(cursor, table_name, valid_column_names)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None:
|
||||||
|
valid = set(valid_column_names)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
for (column_name,) in cursor.fetchall():
|
||||||
|
if str(column_name) not in valid:
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?",
|
||||||
|
(table_name, column_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_table_readonly(cursor, table_name: str) -> int | None:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return int(row[0] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_column_editable(cursor, table_name: str, column_name: str) -> int | None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
""",
|
||||||
|
(table_name, column_name),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return int(row[0] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def list_editable_business_columns(cursor, table_name: str) -> list[str]:
|
||||||
|
physical = list_physical_column_names(cursor, table_name)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, editable
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ?
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
editable_set = {
|
||||||
|
str(column_name)
|
||||||
|
for column_name, editable in cursor.fetchall()
|
||||||
|
if editable is not None and int(editable) == 1
|
||||||
|
}
|
||||||
|
return [name for name in physical if name in editable_set]
|
||||||
|
|
||||||
|
|
||||||
|
def table_allows_manual_create(cursor, table_name: str) -> bool:
|
||||||
|
readonly = get_table_readonly(cursor, table_name)
|
||||||
|
if readonly is None or int(readonly) != 0:
|
||||||
|
return False
|
||||||
|
return bool(list_editable_business_columns(cursor, table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def seed_business_table_display_metadata(cursor, table_meta: dict[str, object], columns: tuple[dict[str, object], ...]) -> None:
|
||||||
|
table_name = str(table_meta["table_name"])
|
||||||
|
upsert_table_metadata(
|
||||||
|
cursor,
|
||||||
|
table_name=table_name,
|
||||||
|
display_name=str(table_meta["display_name"]),
|
||||||
|
description=str(table_meta["description"]) if table_meta.get("description") else None,
|
||||||
|
sort_order=int(table_meta["sort_order"]) if table_meta.get("sort_order") is not None else None,
|
||||||
|
visible=int(table_meta.get("visible", 1)),
|
||||||
|
readonly=int(table_meta.get("readonly", 0)),
|
||||||
|
)
|
||||||
|
seed_column_metadata_batch(
|
||||||
|
cursor,
|
||||||
|
table_name=table_name,
|
||||||
|
columns=columns,
|
||||||
|
valid_column_names=[str(column["column_name"]) for column in columns],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_display_metadata(cursor) -> None:
|
||||||
|
init_display_metadata_tables(cursor)
|
||||||
|
for table_meta, columns in _TABLE_SEEDS:
|
||||||
|
seed_business_table_display_metadata(cursor, table_meta, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_user_tables(cursor) -> Iterable[str]:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||||
|
)
|
||||||
|
for (name,) in cursor.fetchall():
|
||||||
|
if name in METADATA_TABLES:
|
||||||
|
continue
|
||||||
|
yield str(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_identifier(name: str) -> str:
|
||||||
|
return '"' + name.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def list_physical_column_names(cursor, table_name: str) -> list[str]:
|
||||||
|
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
rows.sort(key=lambda row: int(row[0]))
|
||||||
|
return [str(row[1]) for row in rows]
|
||||||
263
tests/test_display_metadata.py
Normal file
263
tests/test_display_metadata.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""数据管理展示元数据:幂等初始化、中文名称、敏感字段权限。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from db.display_metadata import (
|
||||||
|
ACCOUNT_LEASES_TABLE,
|
||||||
|
ACCOUNTS_TABLE,
|
||||||
|
CREDENTIALS_TABLE,
|
||||||
|
PLATFORMS_TABLE,
|
||||||
|
USER_BUSINESS_TABLES,
|
||||||
|
get_table_readonly,
|
||||||
|
init_display_metadata_tables,
|
||||||
|
list_physical_column_names,
|
||||||
|
table_allows_manual_create,
|
||||||
|
upsert_column_metadata,
|
||||||
|
upsert_table_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_cjk(text: str) -> bool:
|
||||||
|
return bool(re.search(r"[\u4e00-\u9fff]", text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metadata_db(clean_db):
|
||||||
|
"""clean_db already ran init_db(); return connection for metadata assertions."""
|
||||||
|
yield clean_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_metadata_idempotent(metadata_db):
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
|
||||||
|
count_after_second = int(cur.fetchone()[0])
|
||||||
|
assert count_after_second == len(USER_BUSINESS_TABLES)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"table_name,expected_display",
|
||||||
|
[
|
||||||
|
(PLATFORMS_TABLE, "平台信息"),
|
||||||
|
(ACCOUNTS_TABLE, "账号信息"),
|
||||||
|
(CREDENTIALS_TABLE, "凭证信息"),
|
||||||
|
(ACCOUNT_LEASES_TABLE, "账号租约"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_table_chinese_display_names(metadata_db, table_name, expected_display):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == expected_display
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_business_columns_have_chinese_display_names(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
for table_name in USER_BUSINESS_TABLES:
|
||||||
|
physical = set(list_physical_column_names(cur, table_name))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, display_name
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ?
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
meta_rows = cur.fetchall()
|
||||||
|
assert len(meta_rows) == len(physical), f"{table_name} 元数据字段数与物理表不一致"
|
||||||
|
for column_name, display_name in meta_rows:
|
||||||
|
assert column_name in physical
|
||||||
|
assert _has_cjk(str(display_name)), f"{table_name}.{column_name} 缺少中文 display_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_credential_fields_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
sensitive = ("secret_ref", "secret_mask", "secret_ciphertext", "secret_storage")
|
||||||
|
for column_name in sensitive:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable, searchable
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
""",
|
||||||
|
(CREDENTIALS_TABLE, column_name),
|
||||||
|
)
|
||||||
|
editable, searchable = cur.fetchone()
|
||||||
|
assert int(editable) == 0, column_name
|
||||||
|
if column_name in ("secret_ref", "secret_ciphertext"):
|
||||||
|
assert int(searchable) == 0, column_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_lease_token_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'lease_token'
|
||||||
|
""",
|
||||||
|
(ACCOUNT_LEASES_TABLE,),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_leases_readonly(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
assert int(get_table_readonly(cur, ACCOUNT_LEASES_TABLE)) == 1
|
||||||
|
assert table_allows_manual_create(cur, ACCOUNT_LEASES_TABLE) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_platforms_key_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'platform_key'
|
||||||
|
""",
|
||||||
|
(PLATFORMS_TABLE,),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_dir_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'profile_dir'
|
||||||
|
""",
|
||||||
|
(ACCOUNTS_TABLE,),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_timestamp_columns_use_datetime_display_type(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
for table_name in USER_BUSINESS_TABLES:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, display_type, editable
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name IN ('created_at', 'updated_at')
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
for column_name, display_type, editable in cur.fetchall():
|
||||||
|
assert display_type == "datetime_unix_seconds", f"{table_name}.{column_name}"
|
||||||
|
assert int(editable) == 0, f"{table_name}.{column_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_tables_hidden_from_user_tables(metadata_db):
|
||||||
|
from db.display_metadata import iter_user_tables
|
||||||
|
|
||||||
|
tables = list(iter_user_tables(metadata_db.cursor()))
|
||||||
|
assert "_jiangchang_tables" not in tables
|
||||||
|
assert "_jiangchang_columns" not in tables
|
||||||
|
assert "_schema_meta" not in tables
|
||||||
|
for name in USER_BUSINESS_TABLES:
|
||||||
|
assert name in tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_reinit_clears_stale_column_metadata(metadata_db):
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO _jiangchang_columns
|
||||||
|
(table_name, column_name, display_name, visible, searchable, editable)
|
||||||
|
VALUES ('accounts', 'stale_field', '陈旧字段', 1, 1, 0)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM _jiangchang_columns
|
||||||
|
WHERE table_name = 'accounts' AND column_name = 'stale_field'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_metadata_detected_in_memory_db():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from db.display_metadata import init_display_metadata, list_physical_column_names
|
||||||
|
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
try:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE platforms (
|
||||||
|
platform_key TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
init_display_metadata_tables(conn.cursor())
|
||||||
|
upsert_table_metadata(
|
||||||
|
conn.cursor(),
|
||||||
|
table_name="platforms",
|
||||||
|
display_name="平台信息",
|
||||||
|
)
|
||||||
|
upsert_column_metadata(
|
||||||
|
conn.cursor(),
|
||||||
|
table_name="platforms",
|
||||||
|
column_name="platform_key",
|
||||||
|
display_name="平台键",
|
||||||
|
editable=0,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
physical = list_physical_column_names(conn.cursor(), "platforms")
|
||||||
|
assert "display_name" in physical
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'platforms'"
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) < len(physical)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Full seed should cover all physical columns
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
try:
|
||||||
|
from db.schema import ensure_schema
|
||||||
|
|
||||||
|
ensure_schema(conn)
|
||||||
|
init_display_metadata(conn.cursor())
|
||||||
|
conn.commit()
|
||||||
|
for table_name in USER_BUSINESS_TABLES:
|
||||||
|
physical = set(list_physical_column_names(conn.cursor(), table_name))
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
meta_cols = {row[0] for row in cur.fetchall()}
|
||||||
|
assert meta_cols == physical, table_name
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
Reference in New Issue
Block a user