# -*- coding: utf-8 -*- """ Account service layer — use cases invoked by CLI commands. Each function handles: 1. Input validation 2. Logging key nodes 3. Calling the repository layer 4. Formatting JSON/stdout output Raw secret is NEVER logged or printed in plaintext. """ import json import os import shutil import subprocess import sys import tempfile import time import uuid from typing import Optional from db.accounts_repo import ( acquire_lease, cleanup_expired_leases, account_exists_with_login_id, find_account_ids, find_accounts, find_credentials, get_account_by_id, get_credential_by_id, get_platform_from_db, insert_account, insert_credential, list_active_leases, list_platforms_from_db, platform_exists, release_lease, update_account_error, update_account_last_used, update_account_session_status, update_credential_last_used, ) from db.connection import get_conn, init_db from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger from util.platforms import ( PLATFORMS, _platform_list_cn_for_help, resolve_platform_key, seed_platforms, ) from util.runtime_paths import ( get_default_profile_dir_for_web, get_skill_data_dir, _runtime_paths_debug_text, ) from service.secret_store import ( mask_secret, make_windows_credential_target, read_secret, write_secret, ) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _map_secret_read_error(exc: BaseException) -> tuple[str, str]: """Map secret read/write exceptions to ERROR:* codes (no secret text in code).""" msg = str(exc) if "SECRET_ENV_MISSING" in msg: return "ERROR:SECRET_ENV_MISSING", msg if "WINDOWS_CREDENTIAL_UNAVAILABLE" in msg: return "ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE", msg return "ERROR:SECRET_READ_FAILED", msg def _credential_ok_line(payload: dict) -> str: """Single-line JSON for credential commands (machine integration).""" return json.dumps({"success": True, **payload}, ensure_ascii=False) def _insert_secret_holder_account( conn, *, platform_key: str, account_label: str, environment: str, role: str, provider_code: str, url: Optional[str], now: int, ) -> int: """Create a minimal accounts row so credentials can join on environment/role.""" return insert_account( conn=conn, platform_key=platform_key, account_label=account_label, login_id=None, login_id_type="api_account", tenant_id=None, environment=environment, role=role, provider_code=provider_code, profile_dir=None, url=url, extra_json="{}", now=now, ) def _err_json(code: str, message: str) -> str: """Return a JSON error line.""" return json.dumps( {"success": False, "error": {"code": code, "message": message}}, ensure_ascii=False, ) def _ok_json(data) -> str: """Return a JSON success line.""" return json.dumps( {"success": True, "data": data}, ensure_ascii=False, ) def _say(msg: str) -> None: """Print to stdout.""" print(msg) def _say_err(msg: str) -> None: """Print to stderr.""" print(msg, file=sys.stderr) def _fail_human(code: str, hint_lines: list[str]) -> None: """Print human-readable error block.""" _say(f"ERROR:{code}") for line in hint_lines: _say(line) def _remove_profile_dir(path: str) -> None: p = (path or "").strip() if not p or not os.path.isdir(p): return try: shutil.rmtree(p) except OSError as e: _say_err(f"WARNING: 未能删除用户数据目录(可手工删):{p}\n {e}") def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: """Resolve platform key or print ERROR:INVALID_PLATFORM and return None.""" key = resolve_platform_key((input_name or "").strip()) if not key: _say(f"ERROR:INVALID_PLATFORM 无法识别的平台「{input_name}」。") if hint: _say(hint) else: _say("支持:" + _platform_list_cn_for_help()) return None return key # ============================================================================ # Platform commands # ============================================================================ def cmd_platform_list(domain: Optional[str] = None) -> None: """platform list [--domain logistics|llm|content]""" log = get_skill_logger() log.info("cli_start subcommand=platform_list domain=%s", domain) init_db() conn = get_conn() try: seed_platforms(conn) platforms = list_platforms_from_db(conn, domain=domain) finally: conn.close() _say(_ok_json({"platforms": platforms})) def cmd_platform_get(input_key: str) -> None: """platform get """ log = get_skill_logger() log.info("cli_start subcommand=platform_get key=%s", input_key) key = _resolve_or_fail(input_key) if not key: return init_db() conn = get_conn() try: plat = get_platform_from_db(conn, key) finally: conn.close() if plat: _say(_ok_json(plat)) else: _say(_err_json("ERROR:INVALID_PLATFORM", f"Platform '{key}' not found in DB.")) # ============================================================================ # Account add-web # ============================================================================ 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], ) -> None: """account add-web — create a web/RPA account record.""" 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_fail(platform_input) if not key: return init_db() platform_spec = PLATFORMS.get(key, {}) now = int(time.time()) safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}" extra = {} if extra_json_str: try: extra = json.loads(extra_json_str) except json.JSONDecodeError: _say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。")) return # Auto-generate or use provided profile_dir if profile_dir_override: resolved_profile_dir = os.path.abspath(profile_dir_override) else: resolved_profile_dir = get_default_profile_dir_for_web( platform_key=key, label=safe_label, login_id=login_id, domain=platform_spec.get("domain", "generic"), ) os.makedirs(resolved_profile_dir, exist_ok=True) resolved_url = (url or "").strip() or platform_spec.get("default_url", "") resolved_provider = provider_code or platform_spec.get("provider_code") or key resolved_extra = json.dumps(extra, ensure_ascii=False) conn = get_conn() try: seed_platforms(conn) new_id = insert_account( conn=conn, platform_key=key, account_label=safe_label, login_id=login_id or None, login_id_type=login_id_type, tenant_id=tenant_id or None, environment=environment, role=role, provider_code=resolved_provider, profile_dir=resolved_profile_dir, url=resolved_url, extra_json=resolved_extra, now=now, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_web_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir) _say(_ok_json({ "id": new_id, "platform_key": key, "account_label": safe_label, "profile_dir": resolved_profile_dir, "url": resolved_url, "environment": environment, "role": role, "tenant_id": tenant_id or "", "provider_code": resolved_provider, "status": "active", "session_status": "unknown", })) # ============================================================================ # Account add-secret # ============================================================================ def cmd_account_add_secret( platform_input: str, credential_type: str, label: Optional[str], secret_storage: str, secret_stdin: bool, secret_ref: Optional[str], account_id: Optional[int], environment: Optional[str], role: Optional[str], extra_json_str: Optional[str], ) -> None: """account add-secret — create a credential record with masked secret.""" log = get_skill_logger() log.info("account_add_secret_attempt platform=%s type=%s storage=%s", platform_input, credential_type, secret_storage) key = _resolve_or_fail(platform_input) if not key: return storage_n = secret_storage.strip().lower() if storage_n not in ("none", "env", "windows_credential"): _say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED", f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential")) return # Validate storage/type combinations if storage_n == "none" and credential_type not in ("browser_profile", "note"): _say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED", "storage=none 仅允许 credential_type=browser_profile 或 note。")) return if storage_n == "env" and not secret_ref: _say(_err_json("ERROR:SECRET_REF_MISSING", "env 存储时必须提供 --secret-ref 环境变量名。")) return init_db() platform_spec = PLATFORMS.get(key, {}) now = int(time.time()) safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}" # For env storage: just use the env var name as ref, mask is optional if storage_n == "env": secret_ref_value = secret_ref.strip() secret_mask_value = mask_secret(f"env:{secret_ref_value}") extra = {} if extra_json_str: try: extra = json.loads(extra_json_str) except json.JSONDecodeError: extra = {} resolved_env = environment or "production" resolved_role = role or "api" extra["environment"] = resolved_env extra["role"] = resolved_role extra_json_final = json.dumps(extra, ensure_ascii=False) resolved_url = (platform_spec.get("default_url") or "").strip() or None resolved_provider = platform_spec.get("provider_code") or key conn = get_conn() try: seed_platforms(conn) holder_id = account_id if holder_id is None: holder_id = _insert_secret_holder_account( conn, platform_key=key, account_label=safe_label, environment=resolved_env, role=resolved_role, provider_code=resolved_provider, url=resolved_url, now=now, ) cred_id = insert_credential( conn=conn, account_id=holder_id, platform_key=key, credential_label=safe_label, credential_type=credential_type, secret_storage=storage_n, secret_ref=secret_ref_value, secret_mask=secret_mask_value, expires_at=None, extra_json=extra_json_final, now=now, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_secret_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_secret_success id=%s platform=%s storage=env ref=%s", cred_id, key, secret_ref_value) _say(_ok_json({ "account_id": holder_id, "credential_id": cred_id, "platform_key": key, "credential_label": safe_label, "credential_type": credential_type, "secret_storage": storage_n, "secret_ref": secret_ref_value, "secret_mask": secret_mask_value, "warning": "env 存储方式:请确保环境变量在运行时可用。", })) return # For windows_credential: read secret from stdin if storage_n == "windows_credential": if not secret_stdin: _say(_err_json("ERROR:CLI_BAD_ARGS", "windows_credential 需要 --secret-stdin 从 stdin 读取 secret。")) return # Read secret from stdin (one line) secret_value = sys.stdin.readline().strip() if not secret_value: _say(_err_json("ERROR:CLI_BAD_ARGS", "stdin 中未读到 secret 值。")) return secret_mask_value = mask_secret(secret_value) cred_label_for_target = safe_label target = make_windows_credential_target(key, credential_type, cred_label_for_target) # Write to Windows Credential Manager try: write_secret("windows_credential", target, secret_value) except (OSError, ValueError) as e: log.error("secret_write_failed storage=windows_credential error=%s", str(e)) _say(_err_json("ERROR:SECRET_WRITE_FAILED", f"写 Windows Credential Manager 失败:{e}")) return extra = {} if extra_json_str: try: extra = json.loads(extra_json_str) except json.JSONDecodeError: extra = {} resolved_env = environment or "production" resolved_role = role or "api" extra["environment"] = resolved_env extra["role"] = resolved_role extra_json_final = json.dumps(extra, ensure_ascii=False) resolved_url = (platform_spec.get("default_url") or "").strip() or None resolved_provider = platform_spec.get("provider_code") or key conn = get_conn() try: seed_platforms(conn) holder_id = account_id if holder_id is None: holder_id = _insert_secret_holder_account( conn, platform_key=key, account_label=safe_label, environment=resolved_env, role=resolved_role, provider_code=resolved_provider, url=resolved_url, now=now, ) cred_id = insert_credential( conn=conn, account_id=holder_id, platform_key=key, credential_label=safe_label, credential_type=credential_type, secret_storage=storage_n, secret_ref=target, secret_mask=secret_mask_value, expires_at=None, extra_json=extra_json_final, now=now, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_secret_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_secret_success id=%s platform=%s storage=windows_credential", cred_id, key) # Clear secret value from memory secret_value = "" _say(_ok_json({ "account_id": holder_id, "credential_id": cred_id, "platform_key": key, "credential_label": safe_label, "credential_type": credential_type, "secret_storage": storage_n, "secret_ref": target, "secret_mask": secret_mask_value, })) return # storage = none (browser_profile / note) conn = get_conn() try: seed_platforms(conn) cred_id = insert_credential( conn=conn, account_id=account_id, platform_key=key, credential_label=safe_label, credential_type=credential_type, secret_storage=storage_n, secret_ref=None, secret_mask="", expires_at=None, extra_json=extra_json_str or "{}", now=now, ) conn.commit() except Exception: conn.rollback() log.exception("account_add_secret_failure") _say(_err_json("ERROR:DB_ERROR", "Database insert failed.")) return finally: conn.close() log.info("account_add_secret_success id=%s platform=%s storage=none", cred_id, key) _say(_ok_json({ "account_id": account_id, "credential_id": cred_id, "platform_key": key, "credential_label": safe_label, "credential_type": credential_type, "secret_storage": storage_n, "secret_ref": "", "secret_mask": "", })) # ============================================================================ # Account get (single) # ============================================================================ def cmd_account_get(account_id, include_credentials: bool = False) -> None: """Get a single account by id. Returns JSON.""" log = get_skill_logger() log.info("cli_start subcommand=account_get id=%r", account_id) init_db() acc = get_account_by_id(account_id) if not acc: log.warning("account_get_not_found id=%r", account_id) _say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。") _say_err(_runtime_paths_debug_text()) return if include_credentials: creds = find_credentials( platform_key=acc["platform_key"], limit=20, ) # Filter to only credentials linked to this account or any for same platform linked_creds = [c for c in creds if c["account_id"] == acc["id"]] if not linked_creds: linked_creds = [c for c in creds if c["account_id"] is None] acc["credentials"] = [ { "id": c["id"], "credential_label": c["credential_label"], "credential_type": c["credential_type"], "secret_storage": c["secret_storage"], "secret_ref": c["secret_ref"], "secret_mask": c["secret_mask"], "status": c["status"], } for c in linked_creds ] _say(json.dumps(acc, ensure_ascii=False)) # Legacy alias def cmd_get(account_id) -> None: """Legacy: get — same as account get .""" cmd_account_get(account_id, include_credentials=False) # ============================================================================ # Account list # ============================================================================ def cmd_account_list( platform_input: Optional[str] = None, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: 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) init_db() key = None if platform_input and platform_input not in ("all", "全部", ""): key = _resolve_or_fail(platform_input) if not key: return accounts = find_accounts( platform_key=key, environment=environment, tenant_id=tenant_id, role=role, limit=limit, ) _say(json.dumps(accounts, ensure_ascii=False)) # Legacy alias def cmd_list_json(platform_input: str, limit: int = 200) -> None: """Legacy: list-json [--limit N]""" log = get_skill_logger() log.info("cli_start subcommand=list_json filter=%r limit=%s", platform_input, limit) init_db() raw = (platform_input or "all").strip() if not raw or raw.lower() == "all" or raw == "全部": key = None else: key = _resolve_or_fail(raw) if not key: return lim = max(1, min(int(limit), 500)) accounts = find_accounts(platform_key=key, limit=lim) _say(json.dumps(accounts, ensure_ascii=False)) # ============================================================================ # Account pick-web # ============================================================================ def cmd_account_pick_web( platform_input: str, environment: Optional[str] = None, tenant_id: Optional[str] = None, role: Optional[str] = None, do_lease: bool = False, ttl_sec: int = 900, holder: Optional[str] = None, purpose: Optional[str] = None, ) -> 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) key = _resolve_or_fail(platform_input) if not key: return init_db() conn = get_conn() try: seed_platforms(conn) finally: conn.close() candidate_ids = find_account_ids( platform_key=key, environment=environment, tenant_id=tenant_id, role=role, ) if not candidate_ids: _say(_err_json("ERROR:NO_ACCOUNT", "没有符合条件的可用账号(status=active 且未被其他 lease 占用)。")) return picked_id = candidate_ids[0] acc = get_account_by_id(picked_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", "选中的账号突然不存在了。")) return # Update last_used_at update_account_last_used(picked_id) result = { "id": acc["id"], "platform_key": acc["platform_key"], "account_label": acc["account_label"], "login_id": acc["login_id"], "profile_dir": acc["profile_dir"], "url": acc["url"], "tenant_id": acc["tenant_id"], "environment": acc["environment"], "role": acc["role"], "provider_code": acc["provider_code"], "session_status": acc["session_status"], } # Lease handling lease_info = None if do_lease: lease_token = uuid.uuid4().hex[:32] resolved_holder = holder or "unknown" try: lease_info = acquire_lease( account_id=picked_id, lease_token=lease_token, holder=resolved_holder, purpose=purpose, ttl_sec=ttl_sec, ) result["lease_token"] = lease_info["lease_token"] result["lease_expires_at"] = lease_info["expires_at"] except ValueError as e: log.warning("account_pick_web_lease_conflict id=%s", picked_id) _say(_err_json("ERROR:LEASE_CONFLICT", str(e))) return log.info("account_pick_web_success id=%s token_prefix=%s", picked_id, lease_info["lease_token"][:8] if lease_info else "none") _say(json.dumps(result, ensure_ascii=False)) # Legacy alias def cmd_pick_web(platform_input: str) -> None: """Legacy: pick-web — same as account pick-web without lease.""" cmd_account_pick_web(platform_input, do_lease=False) # ============================================================================ # Credential pick / resolve # ============================================================================ def cmd_credential_pick( platform_input: str, credential_type: Optional[str] = None, environment: Optional[str] = None, role: Optional[str] = None, reveal: bool = False, ) -> None: """Pick a credential for a platform, optionally with reveal.""" log = get_skill_logger() log.info("cli_start subcommand=credential_pick platform=%s type=%s env=%s role=%s reveal=%s", platform_input, credential_type, environment, role, reveal) key = _resolve_or_fail(platform_input) if not key: return init_db() creds = find_credentials( platform_key=key, credential_type=credential_type, environment=environment, role=role, status="active", limit=1, ) if not creds: _say(_err_json("ERROR:CREDENTIAL_NOT_FOUND", "没有符合条件的可用凭据。")) return cred = creds[0] update_credential_last_used(cred["id"]) result = { "id": cred["id"], "platform_key": cred["platform_key"], "credential_label": cred["credential_label"], "credential_type": cred["credential_type"], "secret_storage": cred["secret_storage"], "secret_ref": cred["secret_ref"], "secret_mask": cred["secret_mask"], "status": cred["status"], } if reveal: try: secret_value = read_secret(cred["secret_storage"], cred["secret_ref"]) result["secret"] = secret_value log.info( "credential_pick_reveal_success id=%s storage=%s", cred["id"], cred["secret_storage"], ) except (ValueError, OSError) as e: code, msg = _map_secret_read_error(e) log.error("credential_pick_reveal_failure id=%s error=%s", cred["id"], msg) _say(_err_json(code, msg)) return else: log.info("credential_pick_success id=%s (masked)", cred["id"]) _say(_credential_ok_line(result)) def cmd_credential_resolve(credential_id: int, reveal: bool = False) -> None: """Resolve a credential by id.""" log = get_skill_logger() log.info("cli_start subcommand=credential_resolve id=%s reveal=%s", credential_id, reveal) init_db() cred = get_credential_by_id(credential_id) if not cred: _say(_err_json("ERROR:CREDENTIAL_NOT_FOUND", f"凭据 id={credential_id} 不存在。")) return if cred["status"] != "active": _say(_err_json("ERROR:CREDENTIAL_DISABLED", f"凭据 id={credential_id} 状态为 {cred['status']},不可用。")) return result = { "id": cred["id"], "account_id": cred["account_id"], "platform_key": cred["platform_key"], "credential_label": cred["credential_label"], "credential_type": cred["credential_type"], "secret_storage": cred["secret_storage"], "secret_ref": cred["secret_ref"], "secret_mask": cred["secret_mask"], "status": cred["status"], } if reveal: try: secret_value = read_secret(cred["secret_storage"], cred["secret_ref"]) result["secret"] = secret_value log.info("credential_resolve_reveal_success id=%s", credential_id) except (ValueError, OSError) as e: code, msg = _map_secret_read_error(e) log.error("credential_resolve_reveal_failure id=%s error=%s", credential_id, msg) _say(_err_json(code, msg)) return else: log.info("credential_resolve_success id=%s (masked)", credential_id) _say(_credential_ok_line(result)) # ============================================================================ # Lease commands # ============================================================================ def cmd_lease_release(lease_token: str) -> None: """lease release """ log = get_skill_logger() log.info("cli_start subcommand=lease_release token_prefix=%s", lease_token[:8]) ok = release_lease(lease_token) if ok: _say(_ok_json({"lease_token": lease_token, "status": "released"})) else: _say(_err_json("ERROR:LEASE_NOT_FOUND", "未找到活跃的租约,或租约已释放/过期。")) def cmd_lease_list(active_only: bool = False) -> None: """lease list [--active]""" log = get_skill_logger() log.info("cli_start subcommand=lease_list active_only=%s", active_only) leases = list_active_leases() if active_only: # Already filtered in list_active_leases pass _say(_ok_json({"leases": leases})) def cmd_lease_cleanup() -> None: """lease cleanup — mark expired leases as expired.""" log = get_skill_logger() log.info("cli_start subcommand=lease_cleanup") count = cleanup_expired_leases() _say(_ok_json({"cleaned": count})) # ============================================================================ # Account mark commands # ============================================================================ def cmd_account_mark_session(account_id: int, session_status: str) -> None: """account mark-session --session-status """ log = get_skill_logger() log.info("account_mark_session id=%s status=%s", account_id, session_status) valid_statuses = ("unknown", "likely_valid", "needs_login", "expired") if session_status not in valid_statuses: _say(_err_json("ERROR:CLI_BAD_ARGS", f"session_status 必须为 {valid_statuses} 之一。")) return acc = get_account_by_id(account_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return update_account_session_status(account_id, session_status) _say(_ok_json({"id": account_id, "session_status": session_status})) def cmd_account_mark_error(account_id: int, error_code: str, error_message: str) -> None: """account mark-error --code --message """ log = get_skill_logger() log.info("account_mark_error id=%s code=%s", account_id, error_code) acc = get_account_by_id(account_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return update_account_error(account_id, error_code, error_message) _say(_ok_json({"id": account_id, "error_code": error_code})) def cmd_account_mark_used(account_id: int) -> None: """account mark-used """ log = get_skill_logger() log.info("account_mark_used id=%s", account_id) acc = get_account_by_id(account_id) if not acc: _say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。")) return update_account_last_used(account_id) _say(_ok_json({"id": account_id, "marked": "used"})) # ============================================================================ # Legacy delete commands (kept for compatibility) # ============================================================================ def cmd_delete_by_id(account_id) -> None: """delete id — legacy""" log = get_skill_logger() log.info("delete_by_id id=%r", account_id) acc = get_account_by_id(account_id) if not acc: _say(f"ERROR:ACCOUNT_NOT_FOUND 账号 id={account_id} 不存在。") _say_err(_runtime_paths_debug_text()) return profile_dir = acc.get("profile_dir", "") conn = get_conn() try: cur = conn.cursor() cur.execute("DELETE FROM accounts WHERE id = ?", (int(account_id),)) # Also delete linked credentials cur.execute("DELETE FROM credentials WHERE account_id = ?", (int(account_id),)) conn.commit() finally: conn.close() _remove_profile_dir(profile_dir) log.info("delete_by_id_done id=%s", account_id) _say(f"✅ 已删除账号 ID {account_id}。") def cmd_delete_by_platform(platform_input: str) -> None: """delete platform — legacy""" log = get_skill_logger() log.info("delete_by_platform platform=%r", platform_input) key = _resolve_or_fail(platform_input) if not key: return conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT id, profile_dir FROM accounts WHERE platform_key = ?", (key,)) rows = cur.fetchall() if not rows: _say("ERROR:NO_ACCOUNTS_FOUND 该平台下没有账号记录。") return for rid, pdir in rows: _remove_profile_dir(pdir) cur.execute("DELETE FROM accounts WHERE platform_key = ?", (key,)) cur.execute( "DELETE FROM credentials WHERE platform_key = ? AND account_id IS NOT NULL", (key,), ) conn.commit() log.info("delete_by_platform_done platform=%s count=%s", key, len(rows)) _say(f"✅ 已删除 {PLATFORMS.get(key, {}).get('display_name', key)} 下共 {len(rows)} 条账号。") finally: conn.close() def cmd_delete_by_platform_phone(platform_input: str, phone: str) -> None: """Legacy: delete by platform+phone — now uses login_id matching""" log = get_skill_logger() log.info("delete_by_platform_phone platform=%r", platform_input) key = _resolve_or_fail(platform_input) if not key: return phone_norm = "".join(c for c in phone if c.isdigit()) conn = get_conn() try: cur = conn.cursor() cur.execute( "SELECT id, profile_dir FROM accounts WHERE platform_key = ? AND login_id = ?", (key, phone_norm), ) row = cur.fetchone() if not row: _say("ERROR:ACCOUNT_NOT_FOUND 该平台下无此手机号。") return rid, pdir = row _remove_profile_dir(pdir) cur.execute("DELETE FROM accounts WHERE id = ?", (rid,)) cur.execute("DELETE FROM credentials WHERE account_id = ?", (rid,)) conn.commit() log.info("delete_by_platform_phone_done id=%s", rid) _say(f"✅ 已删除账号 ID {rid}。") finally: conn.close() # ============================================================================ # Legacy list (human readable) # ============================================================================ def cmd_list(platform="all", limit: int = 10) -> None: """Legacy: human-readable list output.""" log = get_skill_logger() log.info("list filter=%r", platform) init_db() accounts = find_accounts( platform_key=None if (not platform or platform in ("all", "全部")) else platform, limit=limit, ) if not accounts: _say("ERROR:NO_ACCOUNTS_FOUND") _say_err(_runtime_paths_debug_text()) return log.info("list_ok rows=%s", len(accounts)) for i, acc in enumerate(accounts): _say(f"id:{acc['id']}") _say(f"label:{acc['account_label']}") _say(f"platform:{acc['platform_key']}") _say(f"login_id:{acc['login_id']}") _say(f"env:{acc['environment']} role:{acc['role']}") _say(f"status:{acc['status']} session:{acc['session_status']}") _say(f"profile_dir:{acc['profile_dir']}") _say(f"url:{acc['url']}") _say(f"created_at:{acc['created_at']}") _say(f"updated_at:{acc['updated_at']}") if acc.get("last_error_code"): _say(f"last_error:{acc['last_error_code']} {acc.get('last_error_message', '')}") if i < len(accounts) - 1: _say("_______________________________________") _say("")