diff --git a/SKILL.md b/SKILL.md index d34d697..fd6c52f 100644 --- a/SKILL.md +++ b/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.22 +version: 2.0.23 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -28,20 +28,22 @@ allowed-tools: ## 何时使用本技能 1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行) -2. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。 -3. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。 -4. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。 -5. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 -6. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 -7. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 -8. **打开浏览器**:`account open `(或兼容别名 `open `)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理行内按钮均为 `interaction: direct` + `sync`,成功不弹提示。 -9. **打开目录**:`account reveal-profile ` — 在系统文件管理器中打开该账号 `profile_dir`。 -10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 -11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 -12. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 -13. **按 lease 取明文密码**:`account get-credential --reveal --lease-token ` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。 -14. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。 -15. **有则用、无则建(网页账号)**:`account ensure-web --platform

` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。 +2. **登记网页/RPA 账号**:`account add-web` — 平台 + 账号名称 + 可选登录账号即可;环境/角色/登录策略跟平台默认。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。 +3. **复制账号**:`account copy ` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。 +4. **编辑账号**:`account update --id ` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。 +5. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。 +6. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。 +7. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 +8. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 +9. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 +10. **打开浏览器**:`account open `(或兼容别名 `open `)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。 +11. **打开目录**:`account reveal-profile ` — 在系统文件管理器中打开该账号 `profile_dir`。 +12. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 +13. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 +14. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 +15. **按 lease 取明文密码**:`account get-credential --reveal --lease-token ` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。 +16. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。 +17. **有则用、无则建(网页账号)**:`account ensure-web --platform

` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。 ## CLI 调用 diff --git a/assets/actions.json b/assets/actions.json index 6667fa4..4c084a0 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -20,7 +20,64 @@ "platform_key": { "type": "string", "title": "平台", - "description": "关联平台信息表;宿主按列 lookup 渲染下拉" + "display_type": "lookup", + "lookup": { + "ref_table": "platforms", + "value_column": "platform_key", + "label_column": "display_name", + "filter_column": "enabled", + "filter_value": 1 + } + }, + "account_label": { + "type": "string", + "title": "账号名称" + }, + "login_id": { + "type": "string", + "title": "登录账号" + } + } + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }], + "entrypoint": { + "type": "cli", + "command": "account", + "args": [ + "add-web", + "--platform", "{{platform_key}}", + "--label", "{{account_label}}", + "--login-id", "{{login_id}}" + ] + } + }, + { + "id": "account-update", + "label": "编辑账号", + "description": "只改名称、登录账号、标识类型、入口链接和启用状态。", + "replaces": "update", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["accounts"], + "inputMapping": { + "accountId": "$row.$pk", + "account_label": "$row.account_label", + "login_id": "$row.login_id", + "login_id_type": "$row.login_id_type", + "url": "$row.url", + "status": "$row.status" + } + }, + "inputSchema": { + "type": "object", + "required": ["accountId", "account_label", "status"], + "properties": { + "accountId": { + "type": "number", + "title": "编号", + "readOnly": true }, "account_label": { "type": "string", @@ -33,49 +90,64 @@ "login_id_type": { "type": "string", "title": "登录标识类型", - "default": "unknown" - }, - "tenant_id": { - "type": "string", - "title": "租户编号" - }, - "environment": { - "type": "string", - "title": "环境", - "default": "production" - }, - "role": { - "type": "string", - "title": "角色", - "default": "default" - }, - "provider_code": { - "type": "string", - "title": "供应商代码" + "enum": ["unknown", "phone", "email", "username", "customer_code", "api_account"] }, "url": { "type": "string", - "title": "入口链接" + "title": "入口链接", + "display_type": "url" + }, + "status": { + "type": "string", + "title": "状态", + "enum": ["active", "disabled", "needs_review"] } } }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }], "entrypoint": { "type": "cli", "command": "account", "args": [ - "add-web", - "--platform", "{{platform_key}}", + "update", + "--id", "{{accountId}}", "--label", "{{account_label}}", "--login-id", "{{login_id}}", "--login-id-type", "{{login_id_type}}", - "--tenant-id", "{{tenant_id}}", - "--environment", "{{environment}}", - "--role", "{{role}}", - "--provider-code", "{{provider_code}}", - "--url", "{{url}}" + "--url", "{{url}}", + "--status", "{{status}}" ] } }, + { + "id": "account-copy", + "label": "复制", + "description": "复制为同平台新账号:新空 Profile,登录账号留空,名称自动加「副本」。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": ["accounts"], + "inputMapping": { "accountId": "$row.$pk" } + }, + "inputSchema": { + "type": "object", + "required": ["accountId"], + "properties": { + "accountId": { + "type": "number", + "title": "账号 ID", + "readOnly": true + } + } + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }], + "entrypoint": { + "type": "cli", + "command": "account", + "args": ["copy", "{{accountId}}"] + } + }, { "id": "reveal-profile", "label": "打开目录", diff --git a/references/CLI.md b/references/CLI.md index 39b4d91..962b058 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -43,7 +43,9 @@ | CLI | 说明 | |---|---| -| `account add-web --platform

[--login-id ] [--login-id-type ] [...]` | 创建网页/RPA 账号;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint ` | +| `account add-web --platform

[--login-id ] [--login-id-type ] [...]` | 创建网页/RPA 账号;省略 `--login-id-type` 时按登录账号推断;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint ` | +| `account copy ` | 复制为同平台新账号(新空 Profile,名称加「副本」,不拷登录账号/凭证/设备指纹) | +| `account update --id [--label] [--login-id] [--login-id-type] [--url] [--status]` | 更新数据管理编辑表单上的短字段 | | `account add-secret --platform

--credential-type --secret-storage env\|windows_credential\|none\|local_encrypted` | 创建凭据;`local_encrypted` **必须** `--secret-stdin` | | `account get [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) | | `account resolve [--id ] [--platform

] [--login-id <手机号/用户名>] [--label <备注>] [--query <…>]` | **用人话定位一条账号**(成功输出同 `get`);多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT` | @@ -109,6 +111,19 @@ python main.py account add-web \ --- +## account copy / update(数据管理) + +```bash +python main.py account copy 3 +python main.py account update --id 4 --label "抖音 备用" --login-id 13900001111 --status active +``` + +- `copy`:复制平台、环境、角色、入口链接、登录策略;**新建空 Profile**;登录账号留空;名称自动「副本」/「副本 2」。不复制凭证、设备指纹、会话状态。 +- `update`:只改名称、登录账号、标识类型、入口链接、状态。空入口链接回退为平台默认 URL。 +- 数据管理「新增」只填平台 / 账号名称 / 登录账号;「编辑」走 `replaces: update` 短表单;行内「复制」为 `interaction: direct`。 + +--- + ## account add-secret(`local_encrypted` 示例) ```bash diff --git a/references/INTEGRATION.md b/references/INTEGRATION.md index f618c69..8b847ee 100644 --- a/references/INTEGRATION.md +++ b/references/INTEGRATION.md @@ -37,6 +37,8 @@ | 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) | | 初始化 master key | `account init-master-key [--print\|--from-env\|--rotate]` | **单行 JSON** | | 新增网页账号 | `account add-web ...` | `{"success": true, "data": {...}}` | +| 复制网页账号 | `account copy ` | `{"success": true, "message": "已复制为「…」", "data": {...}}` | +| 编辑网页账号 | `account update --id ...` | `{"success": true, "message": "已保存", "data": {...}}` | | 新增凭据 | `account add-secret ...` | `{"success": true, "data": {...}}` | | 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` | | 兼容入口 | `get ` / `list-json

` / `pick-web

` | 同上对应新式命令 | diff --git a/references/README.md b/references/README.md index 4cd43fa..df68994 100644 --- a/references/README.md +++ b/references/README.md @@ -58,4 +58,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA --- -**作者**:深圳匠厂科技有限公司 · **版本**:2.0.0 +**作者**:深圳匠厂科技有限公司 · **版本**:2.0.23 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 7d81556..a3fb229 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -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|resolve|pick-web|mark-session|mark-error|mark-used|open|reveal-profile + account add-web|copy|update|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open|reveal-profile init-master-key|export-credentials|import-credentials credential pick|resolve lease release|list|cleanup @@ -23,7 +23,9 @@ from service.account_crypto_commands import ( from service.account_service import ( cmd_account_add_secret, cmd_account_add_web, + cmd_account_copy, cmd_account_ensure_web, + cmd_account_update, cmd_account_get, cmd_account_get_credential, cmd_account_list, @@ -71,11 +73,14 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager [--url ] [--auth-strategy ] 账号管理: - account add-web --platform

--login-id --login-id-type + account add-web --platform

[--login-id ] [--login-id-type ] [--label

--credential-type --secret-storage env|windows_credential|none|local_encrypted [--secret-ref ] [--secret-stdin] @@ -276,6 +281,13 @@ def main() -> None: sub = av[2] if sub == "add-web": _cmd_add_web(av) + elif sub == "copy": + if len(av) < 4: + print("ERROR:CLI_BAD_ARGS account copy 需要 。") + sys.exit(1) + cmd_account_copy(av[3]) + elif sub == "update": + _cmd_account_update(av) elif sub == "ensure-web": _cmd_ensure_web(av) elif sub == "add-secret": @@ -467,7 +479,7 @@ def main() -> None: def _cmd_add_web(argv: list[str]) -> None: plat = _get_opt(argv, "--platform", "") login_id = _get_opt(argv, "--login-id") - login_id_type = _get_opt(argv, "--login-id-type", "unknown") + login_id_type = _get_opt(argv, "--login-id-type") label = _get_opt(argv, "--label") tenant_id = _get_opt(argv, "--tenant-id") environment = _get_opt(argv, "--environment", "production") @@ -511,6 +523,34 @@ def _cmd_add_web(argv: list[str]) -> None: ) +def _cmd_account_update(argv: list[str]) -> None: + from db.accounts_repo import _UNSET + + aid = _get_opt(argv, "--id") + if not aid and len(argv) >= 4 and not str(argv[3]).startswith("-"): + aid = argv[3] + label = _get_opt(argv, "--label") + login_id_type = _get_opt(argv, "--login-id-type") + status = _get_opt(argv, "--status") + login_id = _UNSET + if "--login-id" in argv: + login_id = _get_opt(argv, "--login-id") or "" + url = _UNSET + if "--url" in argv: + url = _get_opt(argv, "--url") or "" + if not aid: + print("ERROR:CLI_BAD_ARGS account update 需要 --id 。") + return + cmd_account_update( + aid, + label=label, + login_id=login_id, + login_id_type=login_id_type, + url=url, + status=status, + ) + + def _cmd_get_credential(argv: list[str]) -> None: aid_raw = argv[3] try: @@ -619,7 +659,7 @@ def _cmd_pick_web_new(argv: list[str]) -> None: def _cmd_ensure_web(argv: list[str]) -> None: plat = _get_opt(argv, "--platform", "") login_id = _get_opt(argv, "--login-id") - login_id_type = _get_opt(argv, "--login-id-type", "unknown") + login_id_type = _get_opt(argv, "--login-id-type") label = _get_opt(argv, "--label") tenant_id = _get_opt(argv, "--tenant-id") environment = _get_opt(argv, "--environment") @@ -651,7 +691,7 @@ def _cmd_ensure_web(argv: list[str]) -> None: cmd_account_ensure_web( platform_input=plat, login_id=login_id, - login_id_type=login_id_type or "unknown", + login_id_type=login_id_type, label=label, tenant_id=tenant_id, environment=environment, diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index 358289e..8092e59 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -471,6 +471,58 @@ def update_account( ) +def list_account_labels(conn) -> list[str]: + cur = conn.cursor() + cur.execute("SELECT account_label FROM accounts WHERE account_label IS NOT NULL") + return [str(row[0]) for row in cur.fetchall() if row[0]] + + +def update_account_user_fields( + conn, + account_id: int, + *, + account_label: Optional[str] = None, + login_id: Any = _UNSET, + login_id_type: Optional[str] = None, + url: Any = _UNSET, + status: Optional[str] = None, +) -> None: + """Patch fields the data-management edit form is allowed to change.""" + sets: list[str] = [] + params: list[Any] = [] + if account_label is not None: + label = str(account_label).strip() + if not label: + raise ValueError("account_label must not be empty") + sets.append("account_label = ?") + params.append(label) + if login_id is not _UNSET: + text = str(login_id).strip() if login_id is not None else "" + sets.append("login_id = ?") + params.append(text or None) + if login_id_type is not None: + typed = str(login_id_type).strip() or "unknown" + sets.append("login_id_type = ?") + params.append(typed) + if url is not _UNSET: + text = str(url).strip() if url is not None else "" + sets.append("url = ?") + params.append(text) + if status is not None: + sets.append("status = ?") + params.append(status) + if not sets: + return + sets.append("updated_at = ?") + params.append(_now_unix()) + params.append(int(account_id)) + cur = conn.cursor() + cur.execute( + f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?", + tuple(params), + ) + + def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool: cur = conn.cursor() cur.execute( diff --git a/scripts/db/display_metadata.py b/scripts/db/display_metadata.py index c75ca47..8649999 100644 --- a/scripts/db/display_metadata.py +++ b/scripts/db/display_metadata.py @@ -41,6 +41,83 @@ ACCOUNT_ID_LOOKUP_JSON = json.dumps( ensure_ascii=False, ) + +def _enum_options(pairs: tuple[tuple[str, str], ...]) -> str: + return json.dumps( + [{"value": value, "label": label} for value, label in pairs], + ensure_ascii=False, + ) + + +LOGIN_ID_TYPE_OPTIONS_JSON = _enum_options( + ( + ("unknown", "未指定"), + ("phone", "手机号"), + ("email", "邮箱"), + ("username", "用户名"), + ("customer_code", "客户代码"), + ("api_account", "API 账号"), + ) +) +ENVIRONMENT_OPTIONS_JSON = _enum_options( + ( + ("production", "生产"), + ("simulator", "仿真"), + ("staging", "预发"), + ("test", "测试"), + ) +) +ROLE_OPTIONS_JSON = _enum_options( + ( + ("default", "默认"), + ("booking", "订舱"), + ("tracking", "跟踪"), + ("api", "API"), + ("admin", "管理"), + ("sales", "销售"), + ) +) +ACCOUNT_STATUS_OPTIONS_JSON = _enum_options( + ( + ("active", "启用"), + ("disabled", "停用"), + ("needs_review", "待复核"), + ) +) +SESSION_STATUS_OPTIONS_JSON = _enum_options( + ( + ("unknown", "未知"), + ("likely_valid", "可能有效"), + ("needs_login", "需要登录"), + ("expired", "已过期"), + ) +) +AUTH_STRATEGY_OPTIONS_JSON = _enum_options( + ( + ("password_auto", "账密自动"), + ("password_with_captcha", "账密+验证码"), + ("password_plus_2fa", "账密+2FA"), + ("qr_code_manual", "扫码"), + ("per_session_manual", "每次手动"), + ("device_bound", "设备绑定"), + ("client_certificate", "客户端证书"), + ("api_token", "API Token"), + ("sso_redirect", "SSO 跳转"), + ) +) +YES_NO_OPTIONS_JSON = _enum_options((("1", "是"), ("0", "否"))) +ENABLED_OPTIONS_JSON = _enum_options((("1", "启用"), ("0", "停用"))) +DOMAIN_OPTIONS_JSON = _enum_options( + ( + ("logistics", "物流"), + ("llm", "大模型"), + ("content", "内容"), + ("dev", "开发"), + ("sim", "仿真"), + ("generic", "通用"), + ) +) + USER_BUSINESS_TABLES = ( PLATFORMS_TABLE, ACCOUNTS_TABLE, @@ -122,13 +199,28 @@ PLATFORMS_TABLE_METADATA = { 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( + "domain", + "业务域", + description="物流 / 大模型 / 内容等", + options_json=DOMAIN_OPTIONS_JSON, + ), _col("provider_code", "供应商代码", description="供应商或平台代码,可空"), - _col("default_url", "默认链接", description="平台默认入口 URL"), + _col("default_url", "默认链接", description="平台默认入口 URL", display_type="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"), + _col( + "enabled", + "是否启用", + description="启用或停用该平台", + options_json=ENABLED_OPTIONS_JSON, + ), + _col( + "default_auth_strategy", + "默认登录策略", + description="新建账号时的默认登录方式", + options_json=AUTH_STRATEGY_OPTIONS_JSON, + ), _ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"), _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), ) @@ -150,26 +242,67 @@ ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( description="所属平台键(来自平台信息表)", display_type="lookup", options_json=PLATFORM_KEY_LOOKUP_JSON, + editable=0, ), _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( + "login_id_type", + "登录标识类型", + description="未指定 / 手机号 / 邮箱 / 用户名等", + options_json=LOGIN_ID_TYPE_OPTIONS_JSON, + ), + _col("tenant_id", "租户编号", description="租户或客户标识,可空", editable=0), + _col( + "environment", + "环境", + description="生产 / 仿真 / 预发 / 测试", + options_json=ENVIRONMENT_OPTIONS_JSON, + editable=0, + ), + _col( + "role", + "角色", + description="默认 / 订舱 / 跟踪 / API 等", + options_json=ROLE_OPTIONS_JSON, + editable=0, + ), + _col("provider_code", "供应商代码", description="冗余供应商码,便于查询", editable=0), _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 等"), + _col("url", "入口链接", description="账号默认入口 URL", display_type="url"), + _col( + "status", + "状态", + description="启用 / 停用 / 待复核", + options_json=ACCOUNT_STATUS_OPTIONS_JSON, + ), + _col( + "session_status", + "会话状态", + description="运行时登录态,由技能回写", + options_json=SESSION_STATUS_OPTIONS_JSON, + editable=0, + ), _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 策略专用,可空"), + _col( + "auth_strategy", + "登录方式", + description="跟随平台默认,一般无需手改", + options_json=AUTH_STRATEGY_OPTIONS_JSON, + editable=0, + ), + _col( + "session_persistent", + "持久会话", + description="是否需要持久化浏览器会话", + options_json=YES_NO_OPTIONS_JSON, + editable=0, + ), + _col("device_fingerprint", "设备指纹", description="设备绑定策略专用,可空", editable=0), _ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"), _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), ) diff --git a/scripts/service/account_add_commands.py b/scripts/service/account_add_commands.py index 2297278..91e420e 100644 --- a/scripts/service/account_add_commands.py +++ b/scripts/service/account_add_commands.py @@ -36,6 +36,7 @@ from service.secret_store import ( make_windows_credential_target, write_secret, ) +from util.account_identity import resolve_login_id_type from util.logging_config import get_skill_logger from util.platforms import get_platform_spec, seed_platforms from util.profile_shortcut import create_profile_browser_shortcut @@ -63,6 +64,7 @@ def create_web_account_record( ) -> Optional[dict]: """Create a web/RPA account. On error prints JSON and returns None; on success returns payload (no stdout).""" log = get_skill_logger() + login_id_type = resolve_login_id_type(login_id_type, login_id) log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s", platform_input, login_id_type, environment, role) diff --git a/scripts/service/account_ensure_commands.py b/scripts/service/account_ensure_commands.py index 601ae91..c788e34 100644 --- a/scripts/service/account_ensure_commands.py +++ b/scripts/service/account_ensure_commands.py @@ -15,7 +15,7 @@ def cmd_account_ensure_web( platform_input: str, *, login_id: Optional[str] = None, - login_id_type: str = "unknown", + login_id_type: Optional[str] = None, label: Optional[str] = None, tenant_id: Optional[str] = None, environment: Optional[str] = None, diff --git a/scripts/service/account_mutate_commands.py b/scripts/service/account_mutate_commands.py new file mode 100644 index 0000000..83d4281 --- /dev/null +++ b/scripts/service/account_mutate_commands.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +"""Account copy / update CLI commands (data-management 复制 / 编辑).""" +from __future__ import annotations + +import json +from typing import Any, Optional + +from db.accounts_repo import ( + _UNSET, + get_account_by_id, + list_account_labels, + update_account_user_fields, +) +from db.connection import get_conn, init_db +from service._svc_common import _err_json, _say +from service.account_add_commands import create_web_account_record +from util.account_identity import ( + KNOWN_ACCOUNT_STATUSES, + next_copy_label, + resolve_login_id_type, +) +from util.logging_config import get_skill_logger +from util.platforms import get_platform_spec, seed_platforms +from util.profile_shortcut import create_profile_browser_shortcut + + +def _extra_json_str(account: dict) -> Optional[str]: + extra = account.get("extra_json") + if extra is None or extra == "": + return None + if isinstance(extra, (dict, list)): + return json.dumps(extra, ensure_ascii=False) + text = str(extra).strip() + return text or None + + +def _ok_result(data: dict, message: str) -> str: + return json.dumps( + { + "type": "result", + "schema_version": 1, + "status": "success", + "message": message, + "success": True, + "data": data, + }, + ensure_ascii=False, + ) + + +def cmd_account_copy(account_id) -> None: + """Clone a web account: same platform/strategy, new empty Profile, empty login_id.""" + log = get_skill_logger() + log.info("account_copy_attempt id=%r", account_id) + try: + aid = int(account_id) + except (TypeError, ValueError): + _say(_err_json("ERROR:CLI_BAD_ARGS", "account copy 需要整数 id。")) + return + + init_db() + source = get_account_by_id(aid) + if not source: + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。")) + return + + conn = get_conn() + try: + labels = list_account_labels(conn) + finally: + conn.close() + + new_label = next_copy_label(str(source.get("account_label") or ""), labels) + tenant = str(source.get("tenant_id") or "").strip() or None + provider = str(source.get("provider_code") or "").strip() or None + url = str(source.get("url") or "").strip() or None + extra = _extra_json_str(source) + session_persistent = source.get("session_persistent") + try: + session_persistent_i = int(session_persistent) if session_persistent is not None else None + except (TypeError, ValueError): + session_persistent_i = None + + ok_payload = create_web_account_record( + str(source.get("platform_key") or ""), + None, + str(source.get("login_id_type") or "unknown"), + new_label, + tenant, + str(source.get("environment") or "production") or "production", + str(source.get("role") or "default") or "default", + provider, + url, + extra, + None, + auth_strategy=source.get("auth_strategy"), + session_persistent=session_persistent_i, + device_fingerprint=None, + platform_key=str(source.get("platform_key") or "") or None, + ) + if not ok_payload: + return + + ok_payload["copied_from_id"] = aid + log.info( + "account_copy_success from=%s to=%s label=%s", + aid, + ok_payload.get("id"), + new_label, + ) + _say(_ok_result(ok_payload, f"已复制为「{new_label}」")) + + +def cmd_account_update( + account_id, + *, + label: Optional[str] = None, + login_id: Any = _UNSET, + login_id_type: Optional[str] = None, + url: Any = _UNSET, + status: Optional[str] = None, +) -> None: + """Update the short set of user-facing account fields.""" + log = get_skill_logger() + log.info("account_update_attempt id=%r", account_id) + try: + aid = int(account_id) + except (TypeError, ValueError): + _say(_err_json("ERROR:CLI_BAD_ARGS", "account update 需要整数 --id。")) + return + + init_db() + current = get_account_by_id(aid) + if not current: + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。")) + return + + next_label = None + if label is not None: + next_label = str(label).strip() + if not next_label: + _say(_err_json("ERROR:CLI_BAD_ARGS", "账号名称不能为空。")) + return + + next_status = None + if status is not None: + next_status = str(status).strip() + if next_status not in KNOWN_ACCOUNT_STATUSES: + _say(_err_json( + "ERROR:CLI_BAD_ARGS", + f"不支持的状态:{status}。支持:active / disabled / needs_review。", + )) + return + + resolved_login_id = _UNSET + if login_id is not _UNSET: + resolved_login_id = str(login_id).strip() if login_id is not None else "" + + next_type = None + if login_id_type is not None or resolved_login_id is not _UNSET: + lid_for_infer = ( + resolved_login_id + if resolved_login_id is not _UNSET + else current.get("login_id") + ) + explicit = login_id_type if login_id_type is not None else current.get("login_id_type") + next_type = resolve_login_id_type(str(explicit or ""), lid_for_infer) + + resolved_url = _UNSET + if url is not _UNSET: + text = str(url).strip() if url is not None else "" + if text: + resolved_url = text + else: + conn = get_conn() + try: + seed_platforms(conn) + spec = get_platform_spec(str(current.get("platform_key") or ""), conn=conn) + finally: + conn.close() + resolved_url = str(spec.get("default_url") or "") + + conn = get_conn() + try: + update_account_user_fields( + conn, + aid, + account_label=next_label, + login_id=resolved_login_id, + login_id_type=next_type, + url=resolved_url, + status=next_status, + ) + conn.commit() + except ValueError as exc: + conn.rollback() + _say(_err_json("ERROR:CLI_BAD_ARGS", str(exc))) + return + except Exception: + conn.rollback() + log.exception("account_update_failure") + _say(_err_json("ERROR:DB_ERROR", "Database update failed.")) + return + finally: + conn.close() + + updated = get_account_by_id(aid) + if not updated: + _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。")) + return + + profile_dir = str(updated.get("profile_dir") or "").strip() + if profile_dir: + create_profile_browser_shortcut( + profile_dir, + str(updated.get("account_label") or ""), + log, + url=str(updated.get("url") or "") or None, + ) + + log.info("account_update_success id=%s", aid) + _say(_ok_result( + { + "id": updated["id"], + "platform_key": updated.get("platform_key"), + "account_label": updated.get("account_label"), + "login_id": updated.get("login_id") or "", + "login_id_type": updated.get("login_id_type"), + "url": updated.get("url") or "", + "status": updated.get("status"), + }, + "已保存", + )) diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index c184d20..fd19487 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -16,6 +16,7 @@ PyArmor trial per-file line limits. This module re-exports the public API. from service._svc_common import PlatformCallerMeta from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web from service.account_ensure_commands import cmd_account_ensure_web +from service.account_mutate_commands import cmd_account_copy, cmd_account_update from service.account_mark_commands import ( cmd_account_mark_error, cmd_account_mark_session, @@ -50,7 +51,9 @@ __all__ = [ "PlatformCallerMeta", "cmd_account_add_secret", "cmd_account_add_web", + "cmd_account_copy", "cmd_account_ensure_web", + "cmd_account_update", "cmd_account_get", "cmd_account_get_credential", "cmd_account_list", diff --git a/scripts/util/account_identity.py b/scripts/util/account_identity.py new file mode 100644 index 0000000..3fffe7f --- /dev/null +++ b/scripts/util/account_identity.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""账号名称副本编号、登录标识类型推断。""" +from __future__ import annotations + +import re +from typing import Iterable, Optional + +_COPY_SUFFIX_RE = re.compile(r"^(.*?)(?: 副本(?: (\d+))?)?$") +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_CN_MOBILE_RE = re.compile(r"^1\d{10}$") +_PHONE_RE = re.compile(r"^\+?\d{8,15}$") +_PHONE_STRIP_RE = re.compile(r"[\s\-()]") + +KNOWN_LOGIN_ID_TYPES = frozenset( + {"email", "username", "phone", "customer_code", "api_account", "unknown"} +) +KNOWN_ACCOUNT_STATUSES = frozenset({"active", "disabled", "needs_review"}) + + +def infer_login_id_type(login_id: Optional[str]) -> str: + """从登录标识猜类型;空则 unknown。""" + text = (login_id or "").strip() + if not text: + return "unknown" + if _EMAIL_RE.match(text): + return "email" + compact = _PHONE_STRIP_RE.sub("", text) + if _CN_MOBILE_RE.match(compact) or _PHONE_RE.match(compact): + return "phone" + return "username" + + +def resolve_login_id_type(explicit: Optional[str], login_id: Optional[str]) -> str: + """有明确类型(且不是 unknown)时用明确值,否则按 login_id 推断。""" + typed = (explicit or "").strip() + if typed and typed != "unknown": + return typed + return infer_login_id_type(login_id) + + +def next_copy_label(source_label: str, existing_labels: Iterable[str]) -> str: + """在已有名称中生成「原名 副本」/「原名 副本 2」…,避免叠「副本 副本」。""" + raw = (source_label or "").strip() or "账号" + existing = {item.strip().lower() for item in existing_labels if (item or "").strip()} + match = _COPY_SUFFIX_RE.match(raw) + stem = (match.group(1).strip() if match else raw) or raw + n = 1 + while n <= 9999: + candidate = f"{stem} 副本" if n == 1 else f"{stem} 副本 {n}" + if candidate.lower() not in existing: + return candidate + n += 1 + return f"{stem} 副本 {n}" diff --git a/tests/test_account_copy_update.py b/tests/test_account_copy_update.py new file mode 100644 index 0000000..cd999ba --- /dev/null +++ b/tests/test_account_copy_update.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""account copy / update 与数据管理短表单语义。""" +from __future__ import annotations + +import json +import os + +from db.accounts_repo import find_accounts, get_account_by_id + + +def _add(clean_db, **kwargs): + from service.account_service import cmd_account_add_web + + defaults = dict( + platform_input="douyin", + login_id="13800138000", + login_id_type="phone", + label="抖音 默认", + tenant_id="t1", + environment="production", + role="default", + provider_code=None, + url="https://www.douyin.com", + extra_json_str='{"hook":"keep"}', + profile_dir_override=None, + auth_strategy="qr_code_manual", + session_persistent=1, + device_fingerprint=None, + ) + defaults.update(kwargs) + cmd_account_add_web(**defaults) + + +class TestAccountCopy: + def test_copy_clones_platform_not_login_or_profile(self, clean_db, capsys): + from service.account_service import cmd_account_copy + + _add(clean_db) + source = find_accounts(platform_key="douyin")[0] + cmd_account_copy(source["id"]) + out = capsys.readouterr().out.strip() + payload = json.loads(out.splitlines()[-1]) + assert payload["success"] is True + assert payload["type"] == "result" + assert "已复制为" in payload["message"] + copy_id = payload["data"]["id"] + assert copy_id != source["id"] + copied = get_account_by_id(copy_id) + assert copied["platform_key"] == "douyin" + assert copied["account_label"] == "抖音 默认 副本" + assert copied["login_id"] == "" + assert copied["tenant_id"] == "t1" + assert copied["environment"] == "production" + assert copied["role"] == "default" + assert copied["url"] == "https://www.douyin.com" + assert copied["auth_strategy"] == "qr_code_manual" + assert copied["session_persistent"] == 1 + assert copied["device_fingerprint"] is None + assert copied["status"] == "active" + assert copied["session_status"] == "unknown" + assert (copied.get("extra_json") or {}).get("hook") == "keep" + assert copied["profile_dir"] + assert copied["profile_dir"] != source["profile_dir"] + assert os.path.isdir(copied["profile_dir"]) + + def test_copy_second_increments_label(self, clean_db, capsys): + from service.account_service import cmd_account_copy + + _add(clean_db) + source = find_accounts(platform_key="douyin")[0] + cmd_account_copy(source["id"]) + capsys.readouterr() + cmd_account_copy(source["id"]) + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["data"]["account_label"] == "抖音 默认 副本 2" + + def test_copy_missing_id(self, clean_db, capsys): + from service.account_service import cmd_account_copy + + cmd_account_copy(99999) + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["success"] is False + assert payload["error"]["code"] == "ERROR:ACCOUNT_NOT_FOUND" + + +class TestAccountUpdate: + def test_update_short_fields(self, clean_db, capsys): + from service.account_service import cmd_account_update + + _add(clean_db) + source = find_accounts(platform_key="douyin")[0] + cmd_account_update( + source["id"], + label="抖音 备用", + login_id="backup@example.com", + login_id_type="unknown", + url="https://www.douyin.com/user", + status="disabled", + ) + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["success"] is True + assert payload["message"] == "已保存" + updated = get_account_by_id(source["id"]) + assert updated["account_label"] == "抖音 备用" + assert updated["login_id"] == "backup@example.com" + assert updated["login_id_type"] == "email" + assert updated["url"] == "https://www.douyin.com/user" + assert updated["status"] == "disabled" + assert updated["auth_strategy"] == "qr_code_manual" + assert updated["profile_dir"] == source["profile_dir"] + + def test_update_empty_login_clears(self, clean_db, capsys): + from service.account_service import cmd_account_update + + _add(clean_db) + source = find_accounts(platform_key="douyin")[0] + cmd_account_update(source["id"], login_id="", login_id_type="unknown") + capsys.readouterr() + updated = get_account_by_id(source["id"]) + assert updated["login_id"] == "" + assert updated["login_id_type"] == "unknown" + assert updated["account_label"] == "抖音 默认" + + def test_update_rejects_bad_status(self, clean_db, capsys): + from service.account_service import cmd_account_update + + _add(clean_db) + source = find_accounts(platform_key="douyin")[0] + cmd_account_update(source["id"], status="nope") + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["success"] is False + + +class TestAddWebInfersLoginType: + def test_email_without_type(self, clean_db, capsys): + from service.account_service import cmd_account_add_web + + cmd_account_add_web( + platform_input="deepseek", + login_id="a@b.com", + login_id_type=None, + label="ds", + tenant_id=None, + environment="production", + role="default", + provider_code=None, + url=None, + extra_json_str=None, + profile_dir_override=None, + ) + capsys.readouterr() + acc = find_accounts(platform_key="deepseek")[0] + assert acc["login_id_type"] == "email" diff --git a/tests/test_account_identity.py b/tests/test_account_identity.py new file mode 100644 index 0000000..a3e1ee6 --- /dev/null +++ b/tests/test_account_identity.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from util.account_identity import infer_login_id_type, next_copy_label, resolve_login_id_type + + +def test_infer_login_id_type(): + assert infer_login_id_type(None) == "unknown" + assert infer_login_id_type("") == "unknown" + assert infer_login_id_type("demo@maersk.com") == "email" + assert infer_login_id_type("13800138000") == "phone" + assert infer_login_id_type("+8613800138000") == "phone" + assert infer_login_id_type("creator_01") == "username" + + +def test_resolve_login_id_type_keeps_explicit(): + assert resolve_login_id_type("phone", "demo@x.com") == "phone" + assert resolve_login_id_type("unknown", "demo@x.com") == "email" + assert resolve_login_id_type(None, "13800138000") == "phone" + + +def test_next_copy_label_increments(): + assert next_copy_label("抖音 默认", []) == "抖音 默认 副本" + assert next_copy_label("抖音 默认", ["抖音 默认 副本"]) == "抖音 默认 副本 2" + assert next_copy_label("抖音 默认 副本", ["抖音 默认 副本"]) == "抖音 默认 副本 2" + assert next_copy_label( + "抖音 默认", + ["抖音 默认 副本", "抖音 默认 副本 2"], + ) == "抖音 默认 副本 3" diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py index 16af299..c0eca13 100644 --- a/tests/test_display_metadata.py +++ b/tests/test_display_metadata.py @@ -182,6 +182,62 @@ def test_profile_dir_not_editable(metadata_db): assert int(cur.fetchone()[0]) == 0 +def test_account_edit_form_columns_and_enums(metadata_db): + import json + + cur = metadata_db.cursor() + expected_editable = { + "account_label", + "login_id", + "login_id_type", + "url", + "status", + } + readonly_runtime = { + "platform_key", + "tenant_id", + "environment", + "role", + "provider_code", + "session_status", + "auth_strategy", + "session_persistent", + "device_fingerprint", + } + for column_name in expected_editable: + cur.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (ACCOUNTS_TABLE, column_name), + ) + assert int(cur.fetchone()[0]) == 1, column_name + for column_name in readonly_runtime: + cur.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (ACCOUNTS_TABLE, column_name), + ) + assert int(cur.fetchone()[0]) == 0, column_name + + cur.execute( + """ + SELECT options_json FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'status' + """, + (ACCOUNTS_TABLE,), + ) + options = json.loads(cur.fetchone()[0]) + values = {item["value"] for item in options} + labels = {item["label"] for item in options} + assert values == {"active", "disabled", "needs_review"} + assert "启用" in labels + assert "停用" in labels + + def test_timestamp_columns_use_datetime_display_type(metadata_db): cur = metadata_db.cursor() for table_name in USER_BUSINESS_TABLES: diff --git a/tests/test_row_open_actions.py b/tests/test_row_open_actions.py index a1f8811..3edff21 100644 --- a/tests/test_row_open_actions.py +++ b/tests/test_row_open_actions.py @@ -44,6 +44,30 @@ class TestRowOpenActions(unittest.TestCase): self.assertEqual(create.get("replaces"), "create") self.assertEqual(create.get("placements") or [], []) self.assertEqual(create.get("label"), "新增账号") + props = (create.get("inputSchema") or {}).get("properties") or {} + self.assertEqual(set(props), {"platform_key", "account_label", "login_id"}) + + def test_update_takes_over_host_edit(self) -> None: + update = _by_id(_load_actions(), "account-update") + self.assertEqual(update.get("replaces"), "update") + self.assertEqual(update.get("placements") or [], []) + self.assertEqual(update.get("label"), "编辑账号") + props = (update.get("inputSchema") or {}).get("properties") or {} + self.assertEqual( + set(props), + {"accountId", "account_label", "login_id", "login_id_type", "url", "status"}, + ) + self.assertIn("enum", props["login_id_type"]) + self.assertIn("enum", props["status"]) + + def test_copy_is_direct_row_action(self) -> None: + copy = _by_id(_load_actions(), "account-copy") + self.assertEqual(copy.get("label"), "复制") + self.assertEqual(copy.get("placements"), ["row"]) + self.assertEqual(copy.get("interaction"), "direct") + self.assertFalse(copy.get("replaces")) + steps = copy.get("steps") or [] + self.assertTrue(any(s.get("do") == "reloadTable" for s in steps)) if __name__ == "__main__": diff --git a/tests/test_usage.py b/tests/test_usage.py index 3998f4f..def227a 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -27,6 +27,8 @@ def test_usage_includes_all_account_subcommands(): "init-master-key", "export-credentials", "import-credentials", + "copy", + "update", "add-web", "ensure-web", "get-credential",