diff --git a/SKILL.md b/SKILL.md index 8f19a5b..a5767a1 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.10 +version: 2.0.11 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 `(或兼容别名 `open `)— 仅查看,不做站点侧登录判定。 -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 --reveal --lease-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

` — 登记账号 ≠ 已登录;登录仍由业务技能 / `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 `(或兼容别名 `open `)— 仅查看,不做站点侧登录判定。 +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 --reveal --lease-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

` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。 ## CLI 调用 diff --git a/references/CLI.md b/references/CLI.md index 7af19a9..630e0ec 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -40,10 +40,11 @@ | `account add-web --platform

[--login-id ] [--login-id-type ] [...]` | 创建网页/RPA 账号;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint ` | | `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` | | `account get-credential [--reveal --lease-token ]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease | -| `account list [--platform

] [--environment ] [--tenant-id ] [--role ] [--limit N]` | 输出账号 JSON **数组** | -| `account pick-web --platform

[...] [--lease] [...] [--ensure]` | 挑选网页自动化候选;`--ensure` 等同 `ensure-web`(零账号时自动登记) | -| `account ensure-web --platform

[--login-id ] [--label

] [...] [--login-id ] [--label

[...] [--login-id <…>] [--label <…>] [--lease] [...] [--ensure]` | 挑选网页自动化候选;可按手机号/用户名/备注精确筛选;`--ensure` 等同 `ensure-web` | +| `account ensure-web --platform

[--login-id <…>] [--label <…>] [...] [--lease]` | **有则 pick、无则 add-web 再 pick**;指定 `--login-id`/`--label` 时按该标识筛选/创建;均被租约占用时**不新建**,返回 `ERROR:LEASE_CONFLICT` | | `account mark-session --session-status ` | 标记会话状态 | | `account mark-error --code --message ` | 标记错误 | | `account mark-used ` | 标记已使用 | diff --git a/references/ERRORS.md b/references/ERRORS.md index f018bac..e8f4282 100644 --- a/references/ERRORS.md +++ b/references/ERRORS.md @@ -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 | --- diff --git a/references/INTEGRATION.md b/references/INTEGRATION.md index 1323730..f618c69 100644 --- a/references/INTEGRATION.md +++ b/references/INTEGRATION.md @@ -15,6 +15,8 @@ 业务技能首启推荐:`account ensure-web --platform

[--label ...] [--lease --holder ]`,避免因库中尚无账号而 `NO_ACCOUNT`。登记账号 ≠ 站点已登录。 +指定某人读账号时:`account resolve --platform

--login-id <手机号或用户名>`,或 `pick-web --platform

--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 --reveal --lease-token ` | **单行 JSON**,含 `plaintext`(若可解密) | | 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**(stderr 有警告行) | | 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) | diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 13fa23b..90ed93f 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|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 ] [--role ] account get [--with-credentials] account get-credential [--reveal --lease-token ] + account resolve [--id ] [--platform

] [--login-id <手机号/用户名>] + [--label <备注>] [--query ] + # 用人话定位一条账号(成功输出同 account get) account list [--platform

] [--environment ] - [--tenant-id ] [--role ] [--limit N] + [--tenant-id ] [--role ] + [--login-id ] [--label

[--environment ] [--tenant-id ] - [--role ] [--lease] [--ttl-sec 900] + [--role ] [--login-id <手机号/用户名>] [--label <备注>] + [--lease] [--ttl-sec 900] [--holder ] [--purpose ] [--ensure] # 同 ensure-web:无账号时自动登记再 pick - account ensure-web --platform

[--login-id ] [--label

[--login-id <手机号/用户名>] [--label <备注>] [--environment ] [--tenant-id ] [--role ] [--lease] [--ttl-sec 900] [--holder ] [--purpose

] # 有账号则 pick;零账号则按参数 add-web 后再 pick # 有账号但均被租约占用时不新建,返回 LEASE_CONFLICT + # 指定 --login-id/--label 时按该人读标识筛选/创建 account mark-session --session-status account mark-error --code --message account mark-used @@ -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, diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index 6becbcc..358289e 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -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() diff --git a/scripts/service/account_ensure_commands.py b/scripts/service/account_ensure_commands.py index 2e7248e..601ae91 100644 --- a/scripts/service/account_ensure_commands.py +++ b/scripts/service/account_ensure_commands.py @@ -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) diff --git a/scripts/service/account_query_commands.py b/scripts/service/account_query_commands.py index bd62242..2cf7317 100644 --- a/scripts/service/account_query_commands.py +++ b/scripts/service/account_query_commands.py @@ -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 [--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={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] diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index c38a6b8..6ce57e2 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -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", diff --git a/tests/test_account_resolve_pick.py b/tests/test_account_resolve_pick.py new file mode 100644 index 0000000..05a9a54 --- /dev/null +++ b/tests/test_account_resolve_pick.py @@ -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 diff --git a/tests/test_usage.py b/tests/test_usage.py index d3a91d3..6767a47 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -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