feat: account ensure-web (pick-or-create for first-run UX)
All checks were successful
技能自动化发布 / release (push) Successful in 5s

This commit is contained in:
2026-07-18 14:37:16 +08:00
parent 12b1fec0b9
commit e441b0c58e
11 changed files with 470 additions and 17 deletions

View File

@@ -4,7 +4,7 @@ CLI entry point: argv dispatch for account-manager v2.
Subcommand tree:
platform list/get/ensure
account add-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|pick-web|mark-session|mark-error|mark-used|open
init-master-key|export-credentials|import-credentials
credential pick|resolve
lease release|list|cleanup
@@ -23,6 +23,7 @@ from service.account_crypto_commands import (
from service.account_service import (
cmd_account_add_secret,
cmd_account_add_web,
cmd_account_ensure_web,
cmd_account_get,
cmd_account_get_credential,
cmd_account_list,
@@ -82,6 +83,12 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
[--role <role>] [--lease] [--ttl-sec 900]
[--holder <holder>] [--purpose <purpose>]
[--ensure] # 同 ensure-web无账号时自动登记再 pick
account ensure-web --platform <p> [--login-id <id>] [--label <label>]
[--environment <env>] [--tenant-id <t>] [--role <role>]
[--lease] [--ttl-sec 900] [--holder <h>] [--purpose <p>]
# 有账号则 pick零账号则按参数 add-web 后再 pick
# 有账号但均被租约占用时不新建,返回 LEASE_CONFLICT
account mark-session <id> --session-status <status>
account mark-error <id> --code <code> --message <msg>
account mark-used <id>
@@ -246,12 +253,14 @@ def main() -> None:
# ---- account subcommand ----
elif cmd == "account":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS account 需要子命令add-web / get / …)。")
print("ERROR:CLI_BAD_ARGS account 需要子命令add-web / ensure-web / get / …)。")
print(_USAGE)
sys.exit(1)
sub = av[2]
if sub == "add-web":
_cmd_add_web(av)
elif sub == "ensure-web":
_cmd_ensure_web(av)
elif sub == "add-secret":
_cmd_add_secret(av)
elif sub == "get-credential":
@@ -543,11 +552,15 @@ def _cmd_pick_web_new(argv: list[str]) -> None:
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
do_ensure = _has_flag(argv, "--ensure")
if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return
platform_meta = _parse_platform_caller_meta(argv)
if do_ensure:
_cmd_ensure_web(argv)
return
cmd_account_pick_web(
platform_input=plat,
environment=env,
@@ -561,6 +574,61 @@ 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")
label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment")
role = _get_opt(argv, "--role")
provider_code = _get_opt(argv, "--provider-code")
url = _get_opt(argv, "--url")
extra_json_str = _get_opt(argv, "--extra-json")
profile_dir_override = _get_opt(argv, "--profile-dir")
auth_strategy = _get_opt(argv, "--auth-strategy")
session_persistent_raw = _get_opt(argv, "--session-persistent")
device_fingerprint = _get_opt(argv, "--device-fingerprint")
do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
session_persistent: Optional[int] = None
if session_persistent_raw is not None:
try:
session_persistent = int(session_persistent_raw)
except ValueError:
print("ERROR:CLI_BAD_ARGS --session-persistent 必须是整数0 或 1")
return
if not plat:
print("ERROR:CLI_BAD_ARGS account ensure-web 需要 --platform <platform>。")
return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_ensure_web(
platform_input=plat,
login_id=login_id,
login_id_type=login_id_type or "unknown",
label=label,
tenant_id=tenant_id,
environment=environment,
role=role,
provider_code=provider_code,
url=url,
extra_json_str=extra_json_str,
profile_dir_override=profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
do_lease=do_lease,
ttl_sec=ttl,
holder=holder,
purpose=purpose,
platform_meta=platform_meta,
)
def _cmd_cred_pick(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--type")

View File

@@ -284,6 +284,36 @@ def find_accounts(
conn.close()
def count_active_accounts(
platform_key: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
) -> int:
"""Count active accounts matching filters (ignores lease occupancy)."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["status = 'active'", "platform_key = ?"]
params: list = [platform_key]
if environment:
conditions.append("environment = ?")
params.append(environment)
if tenant_id:
conditions.append("tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("role = ?")
params.append(role)
where = " AND ".join(conditions)
cur.execute(f"SELECT COUNT(*) FROM accounts WHERE {where}", params)
row = cur.fetchone()
return int(row[0] or 0) if row else 0
finally:
conn.close()
def find_account_ids(
platform_key: str,
environment: Optional[str] = None,

View File

@@ -42,7 +42,7 @@ from util.profile_shortcut import create_profile_browser_shortcut
from util.runtime_paths import get_default_profile_dir_for_web
def cmd_account_add_web(
def create_web_account_record(
platform_input: str,
login_id: Optional[str],
login_id_type: str,
@@ -59,15 +59,16 @@ def cmd_account_add_web(
session_persistent: Optional[int] = None,
device_fingerprint: Optional[str] = None,
platform_meta: Optional[PlatformCallerMeta] = None,
) -> None:
"""account add-web — create a web/RPA account record."""
platform_key: Optional[str] = None,
) -> Optional[dict]:
"""Create a web/RPA account. On error prints JSON and returns None; on success returns payload (no stdout)."""
log = get_skill_logger()
log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s",
platform_input, login_id_type, environment, role)
key = _resolve_or_auto_register(platform_input, platform_meta)
key = platform_key or _resolve_or_auto_register(platform_input, platform_meta)
if not key:
return
return None
init_db()
now = int(time.time())
@@ -85,7 +86,7 @@ def cmd_account_add_web(
extra = json.loads(extra_json_str)
except json.JSONDecodeError:
_say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。"))
return
return None
if profile_dir_override:
resolved_profile_dir = os.path.abspath(profile_dir_override)
@@ -113,7 +114,7 @@ def cmd_account_add_web(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
f"不支持的 auth_strategy{auth_strategy}",
))
return
return None
resolved_auth = auth_strategy
if session_persistent is None:
@@ -124,7 +125,7 @@ def cmd_account_add_web(
"ERR_INVALID_SESSION_PERSISTENT",
"session_persistent 必须是 0 或 1。",
))
return
return None
sp = session_persistent
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
@@ -132,7 +133,7 @@ def cmd_account_add_web(
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
))
return
return None
new_id = insert_account(
conn=conn,
@@ -157,7 +158,7 @@ def cmd_account_add_web(
conn.rollback()
log.exception("account_add_web_failure")
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
return
return None
finally:
conn.close()
@@ -182,7 +183,47 @@ def cmd_account_add_web(
}
if shortcut_path:
ok_payload["profile_shortcut"] = shortcut_path
_say(_ok_json(ok_payload))
return ok_payload
def cmd_account_add_web(
platform_input: str,
login_id: Optional[str],
login_id_type: str,
label: Optional[str],
tenant_id: Optional[str],
environment: str,
role: str,
provider_code: Optional[str],
url: Optional[str],
extra_json_str: Optional[str],
profile_dir_override: Optional[str],
*,
auth_strategy: Optional[str] = None,
session_persistent: Optional[int] = None,
device_fingerprint: Optional[str] = None,
platform_meta: Optional[PlatformCallerMeta] = None,
) -> None:
"""account add-web — create a web/RPA account record."""
ok_payload = create_web_account_record(
platform_input,
login_id,
login_id_type,
label,
tenant_id,
environment,
role,
provider_code,
url,
extra_json_str,
profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
platform_meta=platform_meta,
)
if ok_payload:
_say(_ok_json(ok_payload))
def cmd_account_add_secret(

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""account ensure-web — pick if present, otherwise add-web then pick."""
from typing import Optional
from db.accounts_repo import count_active_accounts
from db.connection import get_conn, init_db
from service._svc_common import PlatformCallerMeta, _err_json, _resolve_or_auto_register, _say
from service.account_add_commands import create_web_account_record
from service.account_query_commands import cmd_account_pick_web
from util.logging_config import get_skill_logger
from util.platforms import seed_platforms
def cmd_account_ensure_web(
platform_input: str,
*,
login_id: Optional[str] = None,
login_id_type: str = "unknown",
label: Optional[str] = None,
tenant_id: Optional[str] = None,
environment: Optional[str] = None,
role: Optional[str] = None,
provider_code: Optional[str] = None,
url: Optional[str] = None,
extra_json_str: Optional[str] = None,
profile_dir_override: Optional[str] = None,
auth_strategy: Optional[str] = None,
session_persistent: Optional[int] = None,
device_fingerprint: Optional[str] = None,
do_lease: bool = False,
ttl_sec: int = 900,
holder: Optional[str] = None,
purpose: Optional[str] = None,
platform_meta: Optional[PlatformCallerMeta] = None,
) -> None:
"""Pick a web account; if none exist for filters, create one then pick.
Does NOT create a new account when matching accounts exist but are all leased.
Success stdout matches ``account pick-web`` (plus ``account_ensured`` when created).
"""
log = get_skill_logger()
log.info(
"account_ensure_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s",
platform_input,
environment,
tenant_id,
role,
do_lease,
)
key = _resolve_or_auto_register(platform_input, platform_meta)
if not key:
return
init_db()
conn = get_conn()
try:
seed_platforms(conn)
finally:
conn.close()
existing_count = count_active_accounts(
platform_key=key,
environment=environment,
tenant_id=tenant_id,
role=role,
)
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
if not create_label:
create_label = f"{key} 默认"
created_payload = create_web_account_record(
platform_input,
login_id,
login_id_type,
create_label,
tenant_id,
create_env,
create_role,
provider_code,
url,
extra_json_str,
profile_dir_override,
auth_strategy=auth_strategy,
session_persistent=session_persistent,
device_fingerprint=device_fingerprint,
platform_meta=platform_meta,
platform_key=key,
)
if not created_payload:
return
created = True
log.info(
"account_ensure_web_created id=%s platform=%s profile_dir=%s",
created_payload.get("id"),
key,
created_payload.get("profile_dir"),
)
else:
log.info(
"account_ensure_web_reuse platform=%s existing_active=%s",
key,
existing_count,
)
# Capture pick output so we can annotate / remap lease-busy errors.
import io
import json
import sys
buf = io.StringIO()
old_out = sys.stdout
sys.stdout = buf
try:
cmd_account_pick_web(
platform_input,
environment=environment,
tenant_id=tenant_id,
role=role,
do_lease=do_lease,
ttl_sec=ttl_sec,
holder=holder,
purpose=purpose,
platform_meta=platform_meta,
)
finally:
sys.stdout = old_out
raw = buf.getvalue().strip()
if not raw:
_say(_err_json("ERROR:NO_ACCOUNT", "ensure 后未能挑选到账号。"))
return
try:
payload = json.loads(raw)
except json.JSONDecodeError:
_say(raw)
return
if isinstance(payload, dict) and payload.get("success") is False:
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:
_say(_err_json(
"ERROR:LEASE_CONFLICT",
"该平台下已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。",
))
return
_say(raw)
return
if isinstance(payload, dict) and created:
payload["account_ensured"] = True
_say(json.dumps(payload, ensure_ascii=False))
return
_say(raw)

View File

@@ -15,6 +15,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_mark_commands import (
cmd_account_mark_error,
cmd_account_mark_session,
@@ -43,6 +44,7 @@ __all__ = [
"PlatformCallerMeta",
"cmd_account_add_secret",
"cmd_account_add_web",
"cmd_account_ensure_web",
"cmd_account_get",
"cmd_account_get_credential",
"cmd_account_list",