Compare commits

..

1 Commits

Author SHA1 Message Date
7bac647673 feat: resolve/pick accounts by login_id or label
All checks were successful
技能自动化发布 / release (push) Successful in 11s
2026-07-18 15:45:52 +08:00
11 changed files with 387 additions and 30 deletions

View File

@@ -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 <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 调用

View File

@@ -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>` | 标记已使用 |

View File

@@ -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 |
---

View File

@@ -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 |

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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]

View File

@@ -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",

View 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

View File

@@ -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