Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f0d9e45f8 | |||
| 7bac647673 | |||
| 8d36e193b4 |
23
SKILL.md
23
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 账号与凭据管理
|
||||
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
|
||||
version: 2.0.8
|
||||
version: 2.0.12
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -29,16 +29,17 @@ allowed-tools:
|
||||
|
||||
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。
|
||||
2. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
|
||||
3. **查看/列出账号**:`account get/list` — JSON 输出。
|
||||
4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则按调用方参数自动 `add-web` 再返回。
|
||||
5. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||
6. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
|
||||
7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
||||
8. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
||||
9. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
||||
10. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
||||
11. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||
12. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
|
||||
3. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
|
||||
4. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
|
||||
5. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
|
||||
6. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||
7. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
|
||||
8. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
||||
9. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
||||
10. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
||||
11. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
||||
12. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||
13. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
|
||||
|
||||
## CLI 调用
|
||||
|
||||
|
||||
@@ -40,10 +40,11 @@
|
||||
| `account add-web --platform <p> [--login-id <id>] [--login-id-type <type>] [...]` | 创建网页/RPA 账号;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint <fp>` |
|
||||
| `account add-secret --platform <p> --credential-type <t> --secret-storage env\|windows_credential\|none\|local_encrypted` | 创建凭据;`local_encrypted` **必须** `--secret-stdin` |
|
||||
| `account get <id> [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) |
|
||||
| `account resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>] [--label <备注>] [--query <…>]` | **用人话定位一条账号**(成功输出同 `get`);多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT` |
|
||||
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
|
||||
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>] [--limit N]` | 输出账号 JSON **数组** |
|
||||
| `account pick-web --platform <p> [...] [--lease] [...] [--ensure]` | 挑选网页自动化候选;`--ensure` 等同 `ensure-web`(零账号时自动登记) |
|
||||
| `account ensure-web --platform <p> [--login-id <id>] [--label <label>] [...] [--lease]` | **有则 pick、无则 add-web 再 pick**;有账号但均被租约占用时**不新建**,返回 `ERROR:LEASE_CONFLICT`;成功时结构同 `pick-web`,若本次新建则带 `account_ensured: true` |
|
||||
| `account list [--platform <p>] [...] [--login-id <id>] [--label <label>] [--limit N]` | 输出账号 JSON **数组**;可按登录标识/备注筛选 |
|
||||
| `account pick-web --platform <p> [...] [--login-id <…>] [--label <…>] [--lease] [...] [--ensure]` | 挑选网页自动化候选;可按手机号/用户名/备注精确筛选;`--ensure` 等同 `ensure-web` |
|
||||
| `account ensure-web --platform <p> [--login-id <…>] [--label <…>] [...] [--lease]` | **有则 pick、无则 add-web 再 pick**;指定 `--login-id`/`--label` 时按该标识筛选/创建;均被租约占用时**不新建**,返回 `ERROR:LEASE_CONFLICT` |
|
||||
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
|
||||
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
|
||||
| `account mark-used <id>` | 标记已使用 |
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
|
||||
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
|
||||
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
|
||||
| `ERROR:NO_ACCOUNT` | `pick-web` / `resolve` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
|
||||
| `ERROR:AMBIGUOUS_ACCOUNT` | `resolve` / 带 `--login-id`/`--label` 的 `pick-web`:匹配到多条,需改用 `--id` 或补 `--platform` |
|
||||
| `ERROR:LEASE_CONFLICT` | `ensure-web` / `pick-web --ensure`:已有账号但均被租约占用(**不会**自动再建);或同账号重复 acquire lease |
|
||||
|
||||
---
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
业务技能首启推荐:`account ensure-web --platform <p> [--label ...] [--lease --holder <skill>]`,避免因库中尚无账号而 `NO_ACCOUNT`。登记账号 ≠ 站点已登录。
|
||||
|
||||
指定某人读账号时:`account resolve --platform <p> --login-id <手机号或用户名>`,或 `pick-web --platform <p> --login-id <…> --lease`;多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT`。
|
||||
|
||||
## 推荐调用方式
|
||||
|
||||
使用 **`subprocess`** 调用 `scripts/main.py`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`**(或上层包装的 `CLAW_*`)必须与最终用户会话一致——否则会写到另一套目录。路径调试见 [`RUNTIME.md`](RUNTIME.md)。
|
||||
@@ -29,6 +31,7 @@
|
||||
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
|
||||
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
|
||||
| 有则用无则建 | `account ensure-web ...` / `pick-web --ensure` | **同 pick-web**;本次新建时多 `account_ensured: true` |
|
||||
| 用人话定位账号 | `account resolve --login-id …` / `--label …` / `--query …` | **同 account get**(单行账号对象) |
|
||||
| 取加密凭据明文 | `account get-credential <id> --reveal --lease-token <t>` | **单行 JSON**,含 `plaintext`(若可解密) |
|
||||
| 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**(stderr 有警告行) |
|
||||
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) |
|
||||
|
||||
@@ -4,7 +4,7 @@ CLI entry point: argv dispatch for account-manager v2.
|
||||
|
||||
Subcommand tree:
|
||||
platform list/get/ensure
|
||||
account add-web|ensure-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open
|
||||
account add-web|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open
|
||||
init-master-key|export-credentials|import-credentials
|
||||
credential pick|resolve
|
||||
lease release|list|cleanup
|
||||
@@ -31,6 +31,7 @@ from service.account_service import (
|
||||
cmd_account_mark_session,
|
||||
cmd_account_mark_used,
|
||||
cmd_account_pick_web,
|
||||
cmd_account_resolve,
|
||||
cmd_credential_pick,
|
||||
cmd_credential_resolve,
|
||||
cmd_delete_by_id,
|
||||
@@ -78,17 +79,23 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
||||
[--environment <env>] [--role <role>]
|
||||
account get <id> [--with-credentials]
|
||||
account get-credential <account_id> [--reveal --lease-token <token>]
|
||||
account resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>]
|
||||
[--label <备注>] [--query <id或login_id或备注>]
|
||||
# 用人话定位一条账号(成功输出同 account get)
|
||||
account list [--platform <p>] [--environment <env>]
|
||||
[--tenant-id <t>] [--role <role>] [--limit N]
|
||||
[--tenant-id <t>] [--role <role>]
|
||||
[--login-id <id>] [--label <label>] [--limit N]
|
||||
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
|
||||
[--role <role>] [--lease] [--ttl-sec 900]
|
||||
[--role <role>] [--login-id <手机号/用户名>] [--label <备注>]
|
||||
[--lease] [--ttl-sec 900]
|
||||
[--holder <holder>] [--purpose <purpose>]
|
||||
[--ensure] # 同 ensure-web:无账号时自动登记再 pick
|
||||
account ensure-web --platform <p> [--login-id <id>] [--label <label>]
|
||||
account ensure-web --platform <p> [--login-id <手机号/用户名>] [--label <备注>]
|
||||
[--environment <env>] [--tenant-id <t>] [--role <role>]
|
||||
[--lease] [--ttl-sec 900] [--holder <h>] [--purpose <p>]
|
||||
# 有账号则 pick;零账号则按参数 add-web 后再 pick
|
||||
# 有账号但均被租约占用时不新建,返回 LEASE_CONFLICT
|
||||
# 指定 --login-id/--label 时按该人读标识筛选/创建
|
||||
account mark-session <id> --session-status <status>
|
||||
account mark-error <id> --code <code> --message <msg>
|
||||
account mark-used <id>
|
||||
@@ -277,6 +284,8 @@ def main() -> None:
|
||||
cmd_account_get(aid, include_credentials=with_creds)
|
||||
elif sub == "list":
|
||||
_cmd_account_list(av)
|
||||
elif sub == "resolve":
|
||||
_cmd_account_resolve(av)
|
||||
elif sub == "pick-web":
|
||||
_cmd_pick_web_new(av)
|
||||
elif sub == "mark-session":
|
||||
@@ -533,21 +542,37 @@ def _cmd_account_list(argv: list[str]) -> None:
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
login_id = _get_opt(argv, "--login-id")
|
||||
label = _get_opt(argv, "--label")
|
||||
limit = _get_int_opt(argv, "--limit", 200)
|
||||
cmd_account_list(
|
||||
platform_input=plat,
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
login_id=login_id,
|
||||
label=label,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_account_resolve(argv: list[str]) -> None:
|
||||
cmd_account_resolve(
|
||||
account_id=_get_opt(argv, "--id"),
|
||||
platform_input=_get_opt(argv, "--platform"),
|
||||
login_id=_get_opt(argv, "--login-id"),
|
||||
label=_get_opt(argv, "--label"),
|
||||
query=_get_opt(argv, "--query"),
|
||||
)
|
||||
|
||||
|
||||
def _cmd_pick_web_new(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
login_id = _get_opt(argv, "--login-id")
|
||||
label = _get_opt(argv, "--label")
|
||||
do_lease = _has_flag(argv, "--lease")
|
||||
ttl = _get_int_opt(argv, "--ttl-sec", 900)
|
||||
holder = _get_opt(argv, "--holder")
|
||||
@@ -566,6 +591,8 @@ def _cmd_pick_web_new(argv: list[str]) -> None:
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
login_id=login_id,
|
||||
label=label,
|
||||
do_lease=do_lease,
|
||||
ttl_sec=ttl,
|
||||
holder=holder,
|
||||
|
||||
@@ -229,6 +229,26 @@ def get_account_by_id(account_id) -> Optional[dict]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _append_human_ref_filters(
|
||||
conditions: list[str],
|
||||
params: list,
|
||||
*,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
table_alias: str = "",
|
||||
) -> None:
|
||||
"""Optional case-insensitive exact match on login_id / account_label."""
|
||||
prefix = f"{table_alias}." if table_alias else ""
|
||||
lid = (login_id or "").strip()
|
||||
if lid:
|
||||
conditions.append(f"LOWER({prefix}login_id) = LOWER(?)")
|
||||
params.append(lid)
|
||||
lab = (label or "").strip()
|
||||
if lab:
|
||||
conditions.append(f"LOWER({prefix}account_label) = LOWER(?)")
|
||||
params.append(lab)
|
||||
|
||||
|
||||
def find_accounts(
|
||||
platform_key: Optional[str] = None,
|
||||
environment: Optional[str] = None,
|
||||
@@ -236,6 +256,8 @@ def find_accounts(
|
||||
role: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
provider_code: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""Find accounts by optional filters. Returns list of dicts."""
|
||||
@@ -263,6 +285,9 @@ def find_accounts(
|
||||
if provider_code:
|
||||
conditions.append("provider_code = ?")
|
||||
params.append(provider_code)
|
||||
_append_human_ref_filters(
|
||||
conditions, params, login_id=login_id, label=label
|
||||
)
|
||||
|
||||
where = ""
|
||||
if conditions:
|
||||
@@ -289,6 +314,8 @@ def count_active_accounts(
|
||||
environment: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Count active accounts matching filters (ignores lease occupancy)."""
|
||||
conn = get_conn()
|
||||
@@ -306,6 +333,9 @@ def count_active_accounts(
|
||||
if role:
|
||||
conditions.append("role = ?")
|
||||
params.append(role)
|
||||
_append_human_ref_filters(
|
||||
conditions, params, login_id=login_id, label=label
|
||||
)
|
||||
where = " AND ".join(conditions)
|
||||
cur.execute(f"SELECT COUNT(*) FROM accounts WHERE {where}", params)
|
||||
row = cur.fetchone()
|
||||
@@ -319,6 +349,8 @@ def find_account_ids(
|
||||
environment: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
) -> list[int]:
|
||||
"""Return account IDs matching filters, excluding those with active leases."""
|
||||
conn = get_conn()
|
||||
@@ -336,6 +368,9 @@ def find_account_ids(
|
||||
if role:
|
||||
conditions.append("a.role = ?")
|
||||
params.append(role)
|
||||
_append_human_ref_filters(
|
||||
conditions, params, login_id=login_id, label=label, table_alias="a"
|
||||
)
|
||||
|
||||
# Exclude accounts with active non-expired leases
|
||||
now = _now_unix()
|
||||
|
||||
@@ -14,6 +14,7 @@ import sqlite3
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from db.display_metadata import init_display_metadata
|
||||
from db.schema import SCHEMA_VERSION, ensure_schema
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import get_skill_logger
|
||||
@@ -85,6 +86,7 @@ def init_db() -> None:
|
||||
ensure_schema(conn)
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata(cur)
|
||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
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]
|
||||
@@ -59,23 +59,30 @@ def cmd_account_ensure_web(
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
lid = (login_id or "").strip() or None
|
||||
lab = (label or "").strip() or None
|
||||
|
||||
existing_count = count_active_accounts(
|
||||
platform_key=key,
|
||||
environment=environment,
|
||||
tenant_id=tenant_id,
|
||||
role=role,
|
||||
login_id=lid,
|
||||
label=lab,
|
||||
)
|
||||
created = False
|
||||
if existing_count == 0:
|
||||
create_env = (environment or "").strip() or "production"
|
||||
create_role = (role or "").strip() or "default"
|
||||
create_label = (label or "").strip() or None
|
||||
create_label = lab or None
|
||||
if not create_label:
|
||||
create_label = f"{key} 默认"
|
||||
if lid:
|
||||
create_label = f"{key} {lid}"
|
||||
|
||||
created_payload = create_web_account_record(
|
||||
platform_input,
|
||||
login_id,
|
||||
lid,
|
||||
login_id_type,
|
||||
create_label,
|
||||
tenant_id,
|
||||
@@ -95,16 +102,19 @@ def cmd_account_ensure_web(
|
||||
return
|
||||
created = True
|
||||
log.info(
|
||||
"account_ensure_web_created id=%s platform=%s profile_dir=%s",
|
||||
"account_ensure_web_created id=%s platform=%s login_id=%s profile_dir=%s",
|
||||
created_payload.get("id"),
|
||||
key,
|
||||
lid,
|
||||
created_payload.get("profile_dir"),
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"account_ensure_web_reuse platform=%s existing_active=%s",
|
||||
"account_ensure_web_reuse platform=%s existing_active=%s login_id=%s label=%s",
|
||||
key,
|
||||
existing_count,
|
||||
lid,
|
||||
lab,
|
||||
)
|
||||
|
||||
# Capture pick output so we can annotate / remap lease-busy errors.
|
||||
@@ -121,6 +131,8 @@ def cmd_account_ensure_web(
|
||||
environment=environment,
|
||||
tenant_id=tenant_id,
|
||||
role=role,
|
||||
login_id=lid,
|
||||
label=lab,
|
||||
do_lease=do_lease,
|
||||
ttl_sec=ttl_sec,
|
||||
holder=holder,
|
||||
@@ -145,9 +157,10 @@ def cmd_account_ensure_web(
|
||||
err = (payload.get("error") or {}) if isinstance(payload.get("error"), dict) else {}
|
||||
code = str(err.get("code") or "")
|
||||
if code == "ERROR:NO_ACCOUNT" and existing_count > 0 and not created:
|
||||
scope = "该筛选条件下" if (lid or lab) else "该平台下"
|
||||
_say(_err_json(
|
||||
"ERROR:LEASE_CONFLICT",
|
||||
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。",
|
||||
f"{scope}已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。",
|
||||
))
|
||||
return
|
||||
_say(raw)
|
||||
|
||||
@@ -193,12 +193,21 @@ def cmd_account_list(
|
||||
environment: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
limit: int = 200,
|
||||
) -> None:
|
||||
"""account list — output JSON array of accounts matching filters."""
|
||||
log = get_skill_logger()
|
||||
log.info("cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s",
|
||||
platform_input, environment, tenant_id, role)
|
||||
log.info(
|
||||
"cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s login_id=%s label=%s",
|
||||
platform_input,
|
||||
environment,
|
||||
tenant_id,
|
||||
role,
|
||||
login_id,
|
||||
label,
|
||||
)
|
||||
init_db()
|
||||
key = None
|
||||
if platform_input and platform_input not in ("all", "全部", ""):
|
||||
@@ -211,11 +220,115 @@ def cmd_account_list(
|
||||
environment=environment,
|
||||
tenant_id=tenant_id,
|
||||
role=role,
|
||||
login_id=login_id,
|
||||
label=label,
|
||||
limit=limit,
|
||||
)
|
||||
_say(json.dumps(accounts, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_account_resolve(
|
||||
*,
|
||||
account_id: Optional[str] = None,
|
||||
platform_input: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Resolve one account by id, login_id, label, or free-form query.
|
||||
|
||||
Success: same JSON shape as ``account get``.
|
||||
Errors: ACCOUNT_NOT_FOUND / NO_ACCOUNT / AMBIGUOUS_ACCOUNT / CLI_BAD_ARGS.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
init_db()
|
||||
|
||||
aid_raw = (account_id or "").strip()
|
||||
q = (query or "").strip()
|
||||
lid = (login_id or "").strip()
|
||||
lab = (label or "").strip()
|
||||
|
||||
# Free-form: digits → id; otherwise treat as login_id (then label fallback).
|
||||
if q and not aid_raw and not lid and not lab:
|
||||
if q.isdigit():
|
||||
aid_raw = q
|
||||
else:
|
||||
lid = q
|
||||
|
||||
if aid_raw:
|
||||
try:
|
||||
aid = int(aid_raw)
|
||||
except (TypeError, ValueError):
|
||||
_say(_err_json("ERROR:CLI_BAD_ARGS", f"账号 id 必须是整数:{aid_raw}"))
|
||||
return
|
||||
acc = get_account_by_id(aid)
|
||||
if not acc:
|
||||
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
|
||||
return
|
||||
log.info("account_resolve_by_id id=%s", aid)
|
||||
_say(json.dumps(acc, ensure_ascii=False))
|
||||
return
|
||||
|
||||
if not lid and not lab:
|
||||
_say(_err_json(
|
||||
"ERROR:CLI_BAD_ARGS",
|
||||
"account resolve 需要 --id、<数字 query>、--login-id 或 --label(也可用 --query 用户名/手机号)。",
|
||||
))
|
||||
return
|
||||
|
||||
key = None
|
||||
if platform_input and platform_input not in ("all", "全部", ""):
|
||||
key = _resolve_or_fail(platform_input)
|
||||
if not key:
|
||||
return
|
||||
elif not platform_input:
|
||||
# login_id/label without platform: search all platforms
|
||||
key = None
|
||||
|
||||
matches = find_accounts(
|
||||
platform_key=key,
|
||||
login_id=lid or None,
|
||||
label=lab or None,
|
||||
status="active",
|
||||
limit=20,
|
||||
)
|
||||
|
||||
# If query was used as login_id and miss, retry as label.
|
||||
if not matches and q and not login_id and not label and not q.isdigit():
|
||||
matches = find_accounts(
|
||||
platform_key=key,
|
||||
label=q,
|
||||
status="active",
|
||||
limit=20,
|
||||
)
|
||||
|
||||
if not matches:
|
||||
ref = lid or lab or q
|
||||
_say(_err_json(
|
||||
"ERROR:NO_ACCOUNT",
|
||||
f"未找到匹配的账号(ref={ref!r}"
|
||||
+ (f", platform={key}" if key else "")
|
||||
+ ")。",
|
||||
))
|
||||
return
|
||||
if len(matches) > 1:
|
||||
ids = [m.get("id") for m in matches[:10]]
|
||||
_say(_err_json(
|
||||
"ERROR:AMBIGUOUS_ACCOUNT",
|
||||
f"匹配到多条账号,请改用 --id 或补充 --platform。候选 id={ids}",
|
||||
))
|
||||
return
|
||||
|
||||
acc = matches[0]
|
||||
log.info(
|
||||
"account_resolve_ok id=%s login_id=%s label=%s",
|
||||
acc.get("id"),
|
||||
acc.get("login_id"),
|
||||
acc.get("account_label"),
|
||||
)
|
||||
_say(json.dumps(acc, ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
|
||||
"""Legacy: list-json <platform|all> [--limit N]"""
|
||||
log = get_skill_logger()
|
||||
@@ -238,6 +351,8 @@ def cmd_account_pick_web(
|
||||
environment: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
login_id: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
do_lease: bool = False,
|
||||
ttl_sec: int = 900,
|
||||
holder: Optional[str] = None,
|
||||
@@ -246,8 +361,16 @@ def cmd_account_pick_web(
|
||||
) -> None:
|
||||
"""Pick a web/RPA account for use. Optionally acquire a lease."""
|
||||
log = get_skill_logger()
|
||||
log.info("account_pick_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s",
|
||||
platform_input, environment, tenant_id, role, do_lease)
|
||||
log.info(
|
||||
"account_pick_web_attempt platform=%s env=%s tenant=%s role=%s login_id=%s label=%s lease=%s",
|
||||
platform_input,
|
||||
environment,
|
||||
tenant_id,
|
||||
role,
|
||||
login_id,
|
||||
label,
|
||||
do_lease,
|
||||
)
|
||||
|
||||
key = _resolve_or_auto_register(platform_input, platform_meta)
|
||||
if not key:
|
||||
@@ -260,15 +383,45 @@ def cmd_account_pick_web(
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
lid = (login_id or "").strip() or None
|
||||
lab = (label or "").strip() or None
|
||||
|
||||
# Human-ref filters should be unique; ambiguous active matches → explicit error.
|
||||
if lid or lab:
|
||||
active_matches = find_accounts(
|
||||
platform_key=key,
|
||||
environment=environment,
|
||||
tenant_id=tenant_id,
|
||||
role=role,
|
||||
status="active",
|
||||
login_id=lid,
|
||||
label=lab,
|
||||
limit=20,
|
||||
)
|
||||
if len(active_matches) > 1:
|
||||
ids = [m.get("id") for m in active_matches[:10]]
|
||||
_say(_err_json(
|
||||
"ERROR:AMBIGUOUS_ACCOUNT",
|
||||
f"login_id/label 匹配到多条账号,请改用 account get <id>。候选 id={ids}",
|
||||
))
|
||||
return
|
||||
|
||||
candidate_ids = find_account_ids(
|
||||
platform_key=key,
|
||||
environment=environment,
|
||||
tenant_id=tenant_id,
|
||||
role=role,
|
||||
login_id=lid,
|
||||
label=lab,
|
||||
)
|
||||
if not candidate_ids:
|
||||
_say(_err_json("ERROR:NO_ACCOUNT",
|
||||
"没有符合条件的可用账号(status=active 且未被其他 lease 占用)。"))
|
||||
hint = ""
|
||||
if lid or lab:
|
||||
hint = f"(login_id={lid!r} label={lab!r})"
|
||||
_say(_err_json(
|
||||
"ERROR:NO_ACCOUNT",
|
||||
f"没有符合条件的可用账号{hint}(status=active 且未被其他 lease 占用)。",
|
||||
))
|
||||
return
|
||||
|
||||
picked_id = candidate_ids[0]
|
||||
|
||||
@@ -26,6 +26,7 @@ from service.account_query_commands import (
|
||||
cmd_account_get_credential,
|
||||
cmd_account_list,
|
||||
cmd_account_pick_web,
|
||||
cmd_account_resolve,
|
||||
cmd_get,
|
||||
cmd_list_json,
|
||||
cmd_pick_web,
|
||||
@@ -52,6 +53,7 @@ __all__ = [
|
||||
"cmd_account_mark_session",
|
||||
"cmd_account_mark_used",
|
||||
"cmd_account_pick_web",
|
||||
"cmd_account_resolve",
|
||||
"cmd_credential_pick",
|
||||
"cmd_credential_resolve",
|
||||
"cmd_delete_by_id",
|
||||
|
||||
119
tests/test_account_resolve_pick.py
Normal file
119
tests/test_account_resolve_pick.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Human-friendly account resolve / pick-web --login-id / ensure by login_id."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def _capture(fn, *args, **kwargs):
|
||||
buf = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
fn(*args, **kwargs)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _add_web(platform, login_id, label):
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
payload = json.loads(
|
||||
_capture(
|
||||
cmd_account_add_web,
|
||||
platform,
|
||||
login_id,
|
||||
"username",
|
||||
label,
|
||||
None,
|
||||
"production",
|
||||
"default",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
)
|
||||
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
|
||||
return payload["data"]
|
||||
return payload
|
||||
|
||||
|
||||
class TestAccountResolve:
|
||||
def test_resolve_by_login_id(self, clean_db):
|
||||
from service.account_service import cmd_account_resolve
|
||||
|
||||
created = _add_web("maersk", "chendelian", "陈德联")
|
||||
raw = _capture(
|
||||
cmd_account_resolve,
|
||||
platform_input="maersk",
|
||||
login_id="ChenDelian",
|
||||
)
|
||||
acc = json.loads(raw)
|
||||
assert acc.get("success") is not False
|
||||
assert acc["id"] == created["id"]
|
||||
assert acc["login_id"] == "chendelian"
|
||||
|
||||
def test_resolve_by_query_fallback_label(self, clean_db):
|
||||
from service.account_service import cmd_account_resolve
|
||||
|
||||
created = _add_web("maersk", "u1", "我的马士基")
|
||||
raw = _capture(
|
||||
cmd_account_resolve,
|
||||
platform_input="maersk",
|
||||
query="我的马士基",
|
||||
)
|
||||
acc = json.loads(raw)
|
||||
assert acc["id"] == created["id"]
|
||||
|
||||
def test_resolve_ambiguous(self, clean_db):
|
||||
from service.account_service import cmd_account_resolve
|
||||
|
||||
_add_web("maersk", "same", "A")
|
||||
_add_web("cma_cgm", "same", "B")
|
||||
raw = _capture(cmd_account_resolve, login_id="same")
|
||||
err = json.loads(raw)
|
||||
assert err["success"] is False
|
||||
assert err["error"]["code"] == "ERROR:AMBIGUOUS_ACCOUNT"
|
||||
|
||||
|
||||
class TestPickByLoginId:
|
||||
def test_pick_web_filters_login_id(self, clean_db):
|
||||
from service.account_service import cmd_account_pick_web
|
||||
|
||||
a = _add_web("maersk", "alice", "A")
|
||||
_add_web("maersk", "bob", "B")
|
||||
raw = _capture(
|
||||
cmd_account_pick_web,
|
||||
"maersk",
|
||||
login_id="bob",
|
||||
)
|
||||
picked = json.loads(raw)
|
||||
assert picked["login_id"] == "bob"
|
||||
assert picked["id"] != a["id"]
|
||||
|
||||
|
||||
class TestEnsureByLoginId:
|
||||
def test_ensure_creates_named_even_if_platform_has_others(self, clean_db):
|
||||
from db.accounts_repo import find_accounts
|
||||
from service.account_service import cmd_account_ensure_web
|
||||
|
||||
_add_web("maersk", "existing", "已有")
|
||||
before = find_accounts(platform_key="maersk", status="active", limit=50)
|
||||
assert len(before) == 1
|
||||
|
||||
raw = _capture(
|
||||
cmd_account_ensure_web,
|
||||
"maersk",
|
||||
login_id="chendelian",
|
||||
label="陈德联",
|
||||
)
|
||||
result = json.loads(raw)
|
||||
assert result.get("account_ensured") is True
|
||||
assert result["login_id"] == "chendelian"
|
||||
|
||||
after = find_accounts(platform_key="maersk", status="active", limit=50)
|
||||
assert len(after) == 2
|
||||
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()
|
||||
@@ -30,6 +30,8 @@ def test_usage_includes_all_account_subcommands():
|
||||
"ensure-web",
|
||||
"get-credential",
|
||||
"pick-web",
|
||||
"resolve",
|
||||
):
|
||||
assert cmd in usage, f"USAGE 缺少 {cmd}"
|
||||
assert "--ensure" in usage
|
||||
assert "--login-id" in usage
|
||||
|
||||
Reference in New Issue
Block a user