1446 lines
49 KiB
Python
1446 lines
49 KiB
Python
# -*- 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_active_lease_by_token,
|
||
get_credential_by_id,
|
||
get_platform_from_db,
|
||
insert_account,
|
||
insert_credential,
|
||
list_active_leases,
|
||
list_platforms_from_db,
|
||
platform_exists,
|
||
release_lease,
|
||
resolve_auth_strategy_for_platform,
|
||
update_account_error,
|
||
update_account_last_used,
|
||
update_account_session_status,
|
||
update_credential_last_used,
|
||
upsert_platform,
|
||
)
|
||
from db.auth_strategy import (
|
||
ALL_AUTH_STRATEGIES,
|
||
AUTH_STRATEGY_CLIENT_CERTIFICATE,
|
||
AUTH_STRATEGY_DEVICE_BOUND,
|
||
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||
AUTH_STRATEGY_PER_SESSION_MANUAL,
|
||
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||
AUTH_STRATEGY_SSO_REDIRECT,
|
||
)
|
||
from db.credentials_repo import (
|
||
CredentialNotEncryptedError,
|
||
insert_credential_encrypted,
|
||
list_credentials_by_account,
|
||
read_credential_plaintext,
|
||
)
|
||
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 (
|
||
DEFAULT_CAPABILITIES_JSON,
|
||
_platform_list_cn_for_help,
|
||
get_platform_spec,
|
||
is_valid_platform_key_format,
|
||
resolve_platform_key_dynamic,
|
||
seed_platforms,
|
||
)
|
||
from util.runtime_paths import (
|
||
get_default_profile_dir_for_web,
|
||
get_skill_data_dir,
|
||
_runtime_paths_debug_text,
|
||
)
|
||
from service.crypto import CredentialDecryptError
|
||
from service.master_key import MasterKeyMissingError, is_master_key_available
|
||
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 _derive_session_persistent(auth_strategy: str) -> int:
|
||
"""Derive default session_persistent from auth_strategy when CLI omits it."""
|
||
if auth_strategy in (
|
||
AUTH_STRATEGY_QR_CODE_MANUAL,
|
||
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||
AUTH_STRATEGY_DEVICE_BOUND,
|
||
AUTH_STRATEGY_SSO_REDIRECT,
|
||
):
|
||
return 1
|
||
if auth_strategy in (AUTH_STRATEGY_PER_SESSION_MANUAL, AUTH_STRATEGY_CLIENT_CERTIFICATE):
|
||
return 0
|
||
return 1
|
||
|
||
|
||
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,
|
||
auth_strategy=resolve_auth_strategy_for_platform(conn, platform_key),
|
||
)
|
||
|
||
|
||
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 emit structured JSON error and return None."""
|
||
init_db()
|
||
conn = get_conn()
|
||
try:
|
||
result = resolve_platform_key_dynamic(input_name, conn=conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
if result.ok:
|
||
return result.platform_key
|
||
|
||
code = result.error_code or "INVALID_PLATFORM"
|
||
if not code.startswith("ERROR:"):
|
||
code = f"ERROR:{code}"
|
||
_say(_err_json(code, result.message or "无法识别平台。"))
|
||
if hint and result.error_code == "PLATFORM_NOT_REGISTERED":
|
||
_say(hint)
|
||
elif hint:
|
||
_say(hint)
|
||
elif result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"):
|
||
_say("支持:" + _platform_list_cn_for_help())
|
||
return None
|
||
|
||
|
||
# ============================================================================
|
||
# 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 <platform>"""
|
||
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:PLATFORM_NOT_REGISTERED",
|
||
f"Platform '{key}' not found in DB.",
|
||
))
|
||
|
||
|
||
def cmd_platform_ensure(
|
||
key: str,
|
||
display_name: str,
|
||
domain: str = "generic",
|
||
url: str = "",
|
||
auth_strategy: Optional[str] = None,
|
||
aliases: Optional[str] = None,
|
||
capabilities: Optional[str] = None,
|
||
) -> int:
|
||
"""
|
||
幂等地注册或更新平台到 DB。
|
||
输出单行 JSON:{"ok": true, "platform_key": "xxx", "action": "created|updated"}
|
||
"""
|
||
log = get_skill_logger()
|
||
key = (key or "").strip()
|
||
display_name = (display_name or key).strip()
|
||
if not key:
|
||
_say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数')
|
||
return 1
|
||
if not is_valid_platform_key_format(key):
|
||
_say(_err_json(
|
||
"ERROR:INVALID_PLATFORM_KEY",
|
||
f"平台 key 格式非法:「{key}」。建议使用小写字母、数字、下划线或连字符。",
|
||
))
|
||
return 1
|
||
|
||
aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False)
|
||
capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON
|
||
|
||
init_db()
|
||
conn = get_conn()
|
||
try:
|
||
existed = bool(
|
||
conn.execute(
|
||
"SELECT 1 FROM platforms WHERE platform_key = ?", (key,)
|
||
).fetchone()
|
||
)
|
||
upsert_platform(
|
||
conn,
|
||
key=key,
|
||
display_name=display_name,
|
||
domain=domain,
|
||
default_url=url,
|
||
aliases_json=aliases_json,
|
||
capabilities_json=capabilities_json,
|
||
default_auth_strategy=auth_strategy,
|
||
)
|
||
finally:
|
||
conn.close()
|
||
action = "updated" if existed else "created"
|
||
log.info("platform_ensure key=%s action=%s", key, action)
|
||
_say(json.dumps({"ok": True, "platform_key": key, "action": action}, ensure_ascii=False))
|
||
return 0
|
||
|
||
|
||
# ============================================================================
|
||
# 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],
|
||
*,
|
||
auth_strategy: Optional[str] = None,
|
||
session_persistent: Optional[int] = None,
|
||
device_fingerprint: Optional[str] = None,
|
||
) -> 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()
|
||
now = int(time.time())
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
platform_spec = get_platform_spec(key, conn=conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
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)
|
||
if auth_strategy is None:
|
||
resolved_auth = resolve_auth_strategy_for_platform(conn, key)
|
||
else:
|
||
if auth_strategy not in ALL_AUTH_STRATEGIES:
|
||
_say(_err_json(
|
||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||
f"不支持的 auth_strategy:{auth_strategy}",
|
||
))
|
||
return
|
||
resolved_auth = auth_strategy
|
||
|
||
if session_persistent is None:
|
||
sp = _derive_session_persistent(resolved_auth)
|
||
else:
|
||
if session_persistent not in (0, 1):
|
||
_say(_err_json(
|
||
"ERR_INVALID_SESSION_PERSISTENT",
|
||
"session_persistent 必须是 0 或 1。",
|
||
))
|
||
return
|
||
sp = session_persistent
|
||
|
||
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
|
||
_say(_err_json(
|
||
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
|
||
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
|
||
))
|
||
return
|
||
|
||
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,
|
||
auth_strategy=resolved_auth,
|
||
session_persistent=sp,
|
||
device_fingerprint=device_fingerprint,
|
||
)
|
||
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",
|
||
"auth_strategy": resolved_auth,
|
||
"session_persistent": sp,
|
||
"device_fingerprint": device_fingerprint,
|
||
}))
|
||
|
||
|
||
# ============================================================================
|
||
# 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", "local_encrypted"):
|
||
_say(_err_json("ERROR:SECRET_STORAGE_UNSUPPORTED",
|
||
f"不支持的存储方式:{secret_storage}。支持:none / env / windows_credential / local_encrypted"))
|
||
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()
|
||
conn = get_conn()
|
||
try:
|
||
seed_platforms(conn)
|
||
platform_spec = get_platform_spec(key, conn=conn)
|
||
finally:
|
||
conn.close()
|
||
now = int(time.time())
|
||
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}"
|
||
|
||
if storage_n == "local_encrypted":
|
||
if not is_master_key_available():
|
||
_say(_err_json(
|
||
"ERR_MASTER_KEY_MISSING",
|
||
"master key 未初始化。先跑 `account init-master-key`。",
|
||
))
|
||
return
|
||
if not secret_stdin:
|
||
_say(_err_json(
|
||
"ERR_LOCAL_ENCRYPTED_REQUIRES_STDIN",
|
||
"local_encrypted 后端必须 --secret-stdin 输入密文,避免命令历史泄漏。",
|
||
))
|
||
return
|
||
if credential_type not in (
|
||
"password",
|
||
"api_key",
|
||
"api_token",
|
||
"bearer_token",
|
||
"refresh_token",
|
||
"oauth_client",
|
||
):
|
||
_say(_err_json(
|
||
"ERR_LOCAL_ENCRYPTED_TYPE_NOT_ALLOWED",
|
||
f"local_encrypted 不接受 credential_type={credential_type}(仅 secret 类)。",
|
||
))
|
||
return
|
||
if secret_ref:
|
||
_say_err("[add-secret] WARNING: secret_ref ignored for local_encrypted (secret comes from stdin).")
|
||
|
||
extra: dict = {}
|
||
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
|
||
resolved_extra = 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
|
||
|
||
plaintext = sys.stdin.readline().rstrip("\r\n")
|
||
if not plaintext:
|
||
_say(_err_json("ERR_LOCAL_ENCRYPTED_EMPTY_STDIN", "stdin 没读到密码内容。"))
|
||
return
|
||
secret_mask_out = mask_secret(plaintext)
|
||
|
||
conn = get_conn()
|
||
cred_id: Optional[int] = None
|
||
holder_id: Optional[int] = None
|
||
try:
|
||
seed_platforms(conn)
|
||
holder_id = account_id
|
||
if holder_id is not None:
|
||
acc = get_account_by_id(holder_id)
|
||
if not acc:
|
||
_say(_err_json(
|
||
"ERROR:ACCOUNT_NOT_FOUND",
|
||
f"account_id={holder_id} 不存在。",
|
||
))
|
||
return
|
||
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_encrypted(
|
||
conn,
|
||
account_id=holder_id,
|
||
platform_key=key,
|
||
credential_label=safe_label,
|
||
credential_type=credential_type,
|
||
plaintext=plaintext,
|
||
expires_at=None,
|
||
extra_json=resolved_extra,
|
||
now=now,
|
||
)
|
||
conn.commit()
|
||
except MasterKeyMissingError as e:
|
||
conn.rollback()
|
||
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||
return
|
||
except CredentialDecryptError as e:
|
||
conn.rollback()
|
||
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||
return
|
||
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_encrypted_success cred_id=%s account_id=%s",
|
||
cred_id,
|
||
holder_id,
|
||
)
|
||
_say(_ok_json({
|
||
"credential_id": cred_id,
|
||
"account_id": holder_id,
|
||
"platform_key": key,
|
||
"credential_type": credential_type,
|
||
"secret_storage": "local_encrypted",
|
||
"secret_mask": secret_mask_out,
|
||
}))
|
||
return
|
||
|
||
# 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))
|
||
|
||
|
||
def cmd_account_get_credential(
|
||
account_id: int,
|
||
*,
|
||
reveal: bool = False,
|
||
lease_token: Optional[str] = None,
|
||
) -> None:
|
||
"""account get-credential — metadata or plaintext when lease proves access."""
|
||
log = get_skill_logger()
|
||
log.info("cli_start subcommand=account_get_credential id=%s reveal=%s", account_id, reveal)
|
||
|
||
init_db()
|
||
acc = get_account_by_id(account_id)
|
||
if not acc:
|
||
log.warning("account_get_credential_not_found id=%s", account_id)
|
||
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={account_id} 不存在。"))
|
||
return
|
||
|
||
conn = get_conn()
|
||
try:
|
||
creds_all = list_credentials_by_account(conn, account_id)
|
||
creds = [c for c in creds_all if (c.get("status") or "").strip().lower() == "active"]
|
||
if not creds:
|
||
_say(_err_json(
|
||
"ERR_CREDENTIAL_MISSING",
|
||
f"账号 id={account_id} 没有活跃凭据。",
|
||
))
|
||
return
|
||
cred = creds[0]
|
||
|
||
base_out = {
|
||
"success": True,
|
||
"account_id": account_id,
|
||
"credential_id": cred["id"],
|
||
"credential_type": cred["credential_type"],
|
||
"secret_storage": cred["secret_storage"],
|
||
"secret_mask": cred["secret_mask"],
|
||
}
|
||
|
||
if not reveal:
|
||
_say(json.dumps(base_out, ensure_ascii=False))
|
||
return
|
||
|
||
tok = (lease_token or "").strip()
|
||
if not tok:
|
||
log.warning("account_get_credential_reveal_missing_token id=%s", account_id)
|
||
_say(_err_json(
|
||
"ERR_LEASE_INVALID",
|
||
"reveal 必须带 --lease-token。",
|
||
))
|
||
return
|
||
|
||
lease = get_active_lease_by_token(conn, tok)
|
||
lease_prefix = tok[:8]
|
||
if lease is None:
|
||
log.warning(
|
||
"account_get_credential_reveal_bad_lease id=%s lease_prefix=%s",
|
||
account_id,
|
||
lease_prefix,
|
||
)
|
||
_say(_err_json(
|
||
"ERR_LEASE_INVALID",
|
||
"lease token 无效或已过期",
|
||
))
|
||
return
|
||
|
||
if int(lease["account_id"]) != int(account_id):
|
||
log.warning(
|
||
"account_get_credential_reveal_lease_mismatch id=%s lease_account=%s lease_prefix=%s",
|
||
account_id,
|
||
lease["account_id"],
|
||
lease_prefix,
|
||
)
|
||
_say(_err_json(
|
||
"ERR_LEASE_INVALID",
|
||
"lease token 不属于该 account",
|
||
))
|
||
return
|
||
|
||
try:
|
||
pt = read_credential_plaintext(conn, int(cred["id"]))
|
||
except MasterKeyMissingError as e:
|
||
log.warning("account_get_credential_master_missing id=%s", account_id)
|
||
_say(_err_json("ERR_MASTER_KEY_MISSING", str(e)))
|
||
return
|
||
except CredentialDecryptError as e:
|
||
log.warning(
|
||
"account_get_credential_decrypt_failed id=%s cred_id=%s lease_prefix=%s",
|
||
account_id,
|
||
cred["id"],
|
||
lease_prefix,
|
||
)
|
||
_say(_err_json("ERR_CREDENTIAL_DECRYPT_FAILED", str(e)))
|
||
return
|
||
except CredentialNotEncryptedError:
|
||
out = {**base_out, "plaintext": None, "note": "credential storage=none, no plaintext to reveal"}
|
||
log.info(
|
||
"account_get_credential_reveal_note_plain id=%s cred_id=%s lease_prefix=%s",
|
||
account_id,
|
||
cred["id"],
|
||
lease_prefix,
|
||
)
|
||
_say(json.dumps(out, ensure_ascii=False))
|
||
return
|
||
|
||
log.info(
|
||
"account_get_credential_reveal_success account_id=%s cred_id=%s lease_prefix=%s",
|
||
account_id,
|
||
cred["id"],
|
||
lease_prefix,
|
||
)
|
||
_say(json.dumps({**base_out, "plaintext": pt}, ensure_ascii=False))
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# Legacy alias
|
||
def cmd_get(account_id) -> None:
|
||
"""Legacy: get <id> — same as account get <id>."""
|
||
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 <platform|all> [--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"],
|
||
"auth_strategy": acc["auth_strategy"],
|
||
"session_persistent": acc["session_persistent"],
|
||
"device_fingerprint": acc.get("device_fingerprint"),
|
||
}
|
||
|
||
# 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 <platform> — 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 <token>"""
|
||
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 <id> --session-status <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 <id> --code <code> --message <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 <id>"""
|
||
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 <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 <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))
|
||
display = get_platform_spec(key, conn=conn).get("display_name", key)
|
||
_say(f"✅ 已删除 {display} 下共 {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("")
|