Compare commits

...

6 Commits
v2.0.2 ... main

Author SHA1 Message Date
12b1fec0b9 feat: add requirements.txt with cryptography for host venv install
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-11 16:07:23 +08:00
f7f9555683 chore: sync SKILL.md version with release tag v2.0.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:23:34 +08:00
549869b2bb Release v2.0.1: create Chrome shortcut in profile dir on add-web
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-07 16:22:59 +08:00
71bef19514 chore: auto release commit (2026-06-19 11:19:10)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-06-19 11:19:10 +08:00
75c7ea67d5 chore: auto release commit (2026-06-19 11:07:44)
Some checks failed
技能自动化发布 / release (push) Failing after 3s
2026-06-19 11:07:44 +08:00
23f4a2c11a chore: auto release commit (2026-06-19 09:53:29)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-19 09:53:29 +08:00
19 changed files with 2612 additions and 1443 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: 账号与凭据管理 name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token支持 9 种登录策略账密自动、扫码、2FA、SSO 等提供加密本地存储Fernet提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token支持 9 种登录策略账密自动、扫码、2FA、SSO 等提供加密本地存储Fernet提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.0 version: 2.0.7
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
# Fernet 加密local_encrypted 凭据、master key、CLI 启动 import依赖此包。
cryptography>=42.0.0,<45

View File

@@ -141,6 +141,48 @@ def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
return default return default
def _get_all_opts(argv: list[str], flag: str) -> list[str]:
"""Collect all values for a repeatable flag (e.g. --platform-alias)."""
vals: list[str] = []
i = 0
while i < len(argv):
if argv[i] == flag and i + 1 < len(argv):
vals.append(argv[i + 1])
i += 2
else:
i += 1
return vals
def _parse_platform_aliases(argv: list[str]) -> list[str]:
aliases: list[str] = []
for raw in _get_all_opts(argv, "--platform-alias"):
for part in (raw or "").split(","):
s = part.strip()
if s and s not in aliases:
aliases.append(s)
return aliases
def _parse_platform_caller_meta(argv: list[str]):
from service.account_service import PlatformCallerMeta
display_name = _get_opt(argv, "--platform-display-name")
url = _get_opt(argv, "--platform-url")
domain = _get_opt(argv, "--platform-domain")
auth_strategy = _get_opt(argv, "--platform-auth-strategy")
aliases = _parse_platform_aliases(argv)
if not any([display_name, url, domain, auth_strategy, aliases]):
return None
return PlatformCallerMeta(
display_name=display_name,
url=url,
domain=domain,
auth_strategy=auth_strategy,
aliases=aliases,
)
# ========================================================================= # =========================================================================
# Main dispatch # Main dispatch
# ========================================================================= # =========================================================================
@@ -416,6 +458,7 @@ def _cmd_add_web(argv: list[str]) -> None:
if not plat: if not plat:
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。") print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
return return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_add_web( cmd_account_add_web(
platform_input=plat, platform_input=plat,
login_id=login_id, login_id=login_id,
@@ -431,6 +474,7 @@ def _cmd_add_web(argv: list[str]) -> None:
auth_strategy=auth_strategy, auth_strategy=auth_strategy,
session_persistent=session_persistent, session_persistent=session_persistent,
device_fingerprint=device_fingerprint, device_fingerprint=device_fingerprint,
platform_meta=platform_meta,
) )
@@ -503,6 +547,7 @@ def _cmd_pick_web_new(argv: list[str]) -> None:
if not plat: if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。") print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return return
platform_meta = _parse_platform_caller_meta(argv)
cmd_account_pick_web( cmd_account_pick_web(
platform_input=plat, platform_input=plat,
environment=env, environment=env,
@@ -512,6 +557,7 @@ def _cmd_pick_web_new(argv: list[str]) -> None:
ttl_sec=ttl, ttl_sec=ttl,
holder=holder, holder=holder,
purpose=purpose, purpose=purpose,
platform_meta=platform_meta,
) )

View File

@@ -806,6 +806,115 @@ def upsert_platform(
conn.commit() conn.commit()
def ensure_platform_from_caller_meta(
conn,
key: str,
*,
display_name: str,
domain: str = "generic",
default_url: str = "",
aliases: list[str] | None = None,
default_auth_strategy: str | None = None,
capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}',
) -> str:
"""
调用方元数据自动注册平台:不存在则创建;已存在则仅填充空字段,不覆盖非空自定义值。
返回 created | updated | unchanged。
"""
now = int(time.time())
provider_code = key
alias_list: list[str] = []
for item in [key, display_name, *(aliases or [])]:
s = str(item).strip()
if s and s not in alias_list:
alias_list.append(s)
aliases_json = json.dumps(alias_list, ensure_ascii=False)
row = conn.execute(
"""
SELECT display_name, domain, provider_code, default_url, aliases_json,
capabilities_json, default_auth_strategy, enabled
FROM platforms WHERE platform_key = ?
""",
(key,),
).fetchone()
if row is None:
upsert_platform(
conn,
key=key,
display_name=display_name,
domain=domain,
provider_code=provider_code,
default_url=default_url,
aliases_json=aliases_json,
capabilities_json=capabilities_json,
default_auth_strategy=default_auth_strategy,
)
return "created"
cur_display, cur_domain, cur_provider, cur_url, cur_aliases_json, cur_caps, cur_auth, enabled = row
if not enabled:
return "unchanged"
merged_display = (cur_display or "").strip() or display_name
merged_domain = (cur_domain or "").strip() or domain
merged_provider = (cur_provider or "").strip() or provider_code
merged_url = (cur_url or "").strip() or (default_url or "").strip()
merged_auth = cur_auth or default_auth_strategy
existing_aliases = _safe_json_loads(cur_aliases_json, [])
if not isinstance(existing_aliases, list):
existing_aliases = []
merged_aliases: list[str] = []
for item in list(existing_aliases) + alias_list:
s = str(item).strip()
if s and s not in merged_aliases:
merged_aliases.append(s)
merged_aliases_json = json.dumps(merged_aliases, ensure_ascii=False)
merged_caps = cur_caps or capabilities_json
unchanged = (
merged_display == (cur_display or "")
and merged_domain == (cur_domain or "")
and merged_provider == (cur_provider or "")
and merged_url == (cur_url or "")
and merged_aliases_json == (cur_aliases_json or "[]")
and merged_auth == cur_auth
)
if unchanged:
return "unchanged"
conn.execute(
"""
UPDATE platforms SET
display_name = ?,
domain = ?,
provider_code = ?,
default_url = ?,
aliases_json = ?,
capabilities_json = ?,
default_auth_strategy = ?,
updated_at = ?
WHERE platform_key = ?
""",
(
merged_display,
merged_domain,
merged_provider,
merged_url,
merged_aliases_json,
merged_caps,
merged_auth,
now,
key,
),
)
conn.commit()
return "updated"
# ============================================================================ # ============================================================================
# Internal helpers # Internal helpers
# ============================================================================ # ============================================================================

View File

@@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
"""Shared helpers for account service command modules."""
import json
import os
import shutil
import sys
from dataclasses import dataclass, field
from typing import Optional
from db.accounts_repo import (
insert_account,
resolve_auth_strategy_for_platform,
ensure_platform_from_caller_meta,
)
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.connection import get_conn, init_db
from util.logging_config import get_skill_logger
from util.platforms import (
DEFAULT_CAPABILITIES_JSON,
PlatformResolveResult,
_platform_list_cn_for_help,
is_valid_platform_key_format,
resolve_platform_key_dynamic,
seed_platforms,
)
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}")
@dataclass
class PlatformCallerMeta:
"""调用方为未注册自定义平台提供的自动注册元数据pick-web / add-web"""
display_name: Optional[str] = None
url: Optional[str] = None
domain: Optional[str] = None
auth_strategy: Optional[str] = None
aliases: list[str] = field(default_factory=list)
def is_sufficient_for_auto_register(self) -> bool:
return bool((self.display_name or "").strip() and (self.url or "").strip())
def _emit_platform_resolve_error(result: PlatformResolveResult, hint: str = "") -> None:
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:
_say(hint)
def _resolve_or_auto_register(
platform_input: str,
meta: Optional[PlatformCallerMeta] = None,
*,
hint: str = "",
) -> Optional[str]:
"""解析平台;未注册时可在元数据充足时自动 ensure幂等、仅填空字段"""
init_db()
conn = get_conn()
try:
result = resolve_platform_key_dynamic(platform_input, conn=conn)
if result.ok:
return result.platform_key
if result.error_code in ("PLATFORM_DISABLED", "INVALID_PLATFORM_KEY", "INVALID_PLATFORM"):
_emit_platform_resolve_error(result, hint=hint)
return None
if result.error_code != "PLATFORM_NOT_REGISTERED":
_emit_platform_resolve_error(result, hint=hint)
if result.error_code not in (
"PLATFORM_NOT_REGISTERED",
"INVALID_PLATFORM_KEY",
"PLATFORM_DISABLED",
):
_say("支持:" + _platform_list_cn_for_help())
return None
raw_key = (platform_input or "").strip().lower()
if not is_valid_platform_key_format(raw_key):
_emit_platform_resolve_error(
PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=result.message or f"平台 key 格式非法:「{platform_input}」。",
),
hint=hint,
)
return None
if not meta or not meta.is_sufficient_for_auto_register():
_emit_platform_resolve_error(
PlatformResolveResult(
error_code="PLATFORM_NOT_REGISTERED",
message=(
f"平台 {raw_key} 尚未注册。"
"请传入 --platform-display-name 与 --platform-url"
"或先执行 account platform ensure。"
),
),
hint=hint,
)
return None
if meta.auth_strategy and meta.auth_strategy not in ALL_AUTH_STRATEGIES:
_say(_err_json(
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
f"不支持的 --platform-auth-strategy{meta.auth_strategy}",
))
return None
seed_platforms(conn)
action = ensure_platform_from_caller_meta(
conn,
raw_key,
display_name=(meta.display_name or "").strip(),
domain=(meta.domain or "generic").strip() or "generic",
default_url=(meta.url or "").strip(),
aliases=meta.aliases,
default_auth_strategy=meta.auth_strategy,
capabilities_json=DEFAULT_CAPABILITIES_JSON,
)
get_skill_logger().info(
"platform_auto_registered key=%s action=%s", raw_key, action
)
return raw_key
finally:
conn.close()
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
_emit_platform_resolve_error(result, hint=hint)
if result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"):
_say("支持:" + _platform_list_cn_for_help())
return None

View File

@@ -0,0 +1,545 @@
# -*- coding: utf-8 -*-
"""Account creation CLI commands (add-web, add-secret)."""
import json
import os
import sys
import time
from typing import Optional
from db.accounts_repo import (
get_account_by_id,
insert_account,
insert_credential,
resolve_auth_strategy_for_platform,
)
from db.auth_strategy import (
ALL_AUTH_STRATEGIES,
AUTH_STRATEGY_DEVICE_BOUND,
)
from db.connection import get_conn, init_db
from db.credentials_repo import insert_credential_encrypted
from service._svc_common import (
PlatformCallerMeta,
_derive_session_persistent,
_err_json,
_insert_secret_holder_account,
_ok_json,
_resolve_or_auto_register,
_resolve_or_fail,
_say,
_say_err,
)
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,
write_secret,
)
from util.logging_config import get_skill_logger
from util.platforms import get_platform_spec, seed_platforms
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(
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."""
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)
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
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()
shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log)
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
ok_payload = {
"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,
}
if shortcut_path:
ok_payload["profile_shortcut"] = shortcut_path
_say(_ok_json(ok_payload))
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
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
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
if storage_n == "windows_credential":
if not secret_stdin:
_say(_err_json("ERROR:CLI_BAD_ARGS",
"windows_credential 需要 --secret-stdin 从 stdin 读取 secret。"))
return
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)
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)
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
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": "",
}))

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""Account status mark CLI commands."""
from db.accounts_repo import (
get_account_by_id,
update_account_error,
update_account_last_used,
update_account_session_status,
)
from service._svc_common import _err_json, _ok_json, _say
from util.logging_config import get_skill_logger
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"}))

View File

@@ -0,0 +1,325 @@
# -*- coding: utf-8 -*-
"""Account query and pick CLI commands."""
import json
import uuid
from typing import Optional
from db.accounts_repo import (
acquire_lease,
find_account_ids,
find_accounts,
find_credentials,
get_account_by_id,
get_active_lease_by_token,
update_account_last_used,
)
from db.connection import get_conn, init_db
from db.credentials_repo import (
CredentialNotEncryptedError,
list_credentials_by_account,
read_credential_plaintext,
)
from service._svc_common import (
PlatformCallerMeta,
_err_json,
_resolve_or_auto_register,
_resolve_or_fail,
_say,
_say_err,
)
from service.crypto import CredentialDecryptError
from service.master_key import MasterKeyMissingError
from util.logging_config import get_skill_logger
from util.platforms import seed_platforms
from util.runtime_paths import _runtime_paths_debug_text
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,
)
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()
def cmd_get(account_id) -> None:
"""Legacy: get <id> — same as account get <id>."""
cmd_account_get(account_id, include_credentials=False)
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))
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))
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,
platform_meta: Optional[PlatformCallerMeta] = 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_auto_register(platform_input, platform_meta)
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_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_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))
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)

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,8 @@ import subprocess
import sys import sys
import tempfile import tempfile
from db.connection import init_db from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import ( from util.logging_config import (
get_skill_logger, get_skill_logger,
subprocess_env_with_trace, subprocess_env_with_trace,
@@ -33,13 +33,12 @@ def _win_find_exe(candidates):
return None return None
def resolve_chromium_channel(): def resolve_chromium_executable():
""" """
Playwright channel: 'chrome' | 'msedge' | None Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
""" """
if sys.platform != "win32": if sys.platform != "win32":
return "chrome" return None
pf = os.environ.get("ProgramFiles", r"C:\Program Files") pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
@@ -53,7 +52,7 @@ def resolve_chromium_channel():
] ]
) )
if chrome: if chrome:
return "chrome" return chrome
edge = _win_find_exe( edge = _win_find_exe(
[ [
@@ -63,17 +62,58 @@ def resolve_chromium_channel():
] ]
) )
if edge: if edge:
return "msedge" return edge
return None return None
def resolve_chromium_channel():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
"""
if sys.platform != "win32":
return "chrome"
exe = resolve_chromium_executable()
if not exe:
return None
if os.path.basename(exe).lower() == "msedge.exe":
return "msedge"
return "chrome"
def _print_browser_install_hint(): def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装") print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/") print(" • Chrome: https://www.google.com/chrome/")
print(" • Edge: https://www.microsoft.com/zh-cn/edge") print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def resolve_account_browser_url(account: dict, conn=None) -> str:
"""account.url 优先,其次 platforms 表 default_url最后内置 PLATFORMS。"""
url = (account.get("url") or "").strip()
if url:
return url
pk = (account.get("platform_key") or "").strip()
if not pk:
return ""
close_conn = False
if conn is None:
init_db()
conn = get_conn()
close_conn = True
try:
plat = get_platform_from_db(conn, pk)
if plat and (plat.get("default_url") or "").strip():
return plat["default_url"].strip()
finally:
if close_conn:
conn.close()
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
def cmd_open(account_id): def cmd_open(account_id):
""" """
打开该账号的持久化浏览器,仅用于肉眼核对。 打开该账号的持久化浏览器,仅用于肉眼核对。
@@ -109,9 +149,11 @@ def cmd_open(account_id):
return return
os.makedirs(profile_dir, exist_ok=True) os.makedirs(profile_dir, exist_ok=True)
# Use account url, fall back to platform default conn = get_conn()
platform_spec = PLATFORMS.get(target["platform_key"], {}) try:
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "") url = resolve_account_browser_url(target, conn=conn)
finally:
conn.close()
log.info( log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s", "browser_open_context profile_dir=%s url=%s channel=%s platform=%s",

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
"""Credential pick and resolve CLI commands."""
from typing import Optional
from db.accounts_repo import find_credentials, get_credential_by_id, update_credential_last_used
from db.connection import init_db
from service._svc_common import (
_credential_ok_line,
_err_json,
_map_secret_read_error,
_resolve_or_fail,
_say,
)
from service.secret_store import read_secret
from util.logging_config import get_skill_logger
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))

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""Lease management CLI commands."""
from db.accounts_repo import cleanup_expired_leases, list_active_leases, release_lease
from service._svc_common import _err_json, _ok_json, _say
from util.logging_config import get_skill_logger
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:
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}))

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Legacy delete and human-readable list CLI commands."""
from db.accounts_repo import get_account_by_id, find_accounts
from db.connection import get_conn, init_db
from service._svc_common import (
_remove_profile_dir,
_resolve_or_fail,
_say,
_say_err,
)
from util.logging_config import get_skill_logger
from util.platforms import get_platform_spec
from util.runtime_paths import _runtime_paths_debug_text
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),))
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()
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("")

View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""Platform management CLI commands."""
import json
from typing import Optional
from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform
from db.connection import get_conn, init_db
from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say
from util.logging_config import get_skill_logger
from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms
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

View File

@@ -6,11 +6,17 @@ Central authoritative registry for all platforms (logistics, LLM, content, etc.)
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms(). Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
""" """
import json import json
import re
import time import time
from dataclasses import dataclass
from typing import Any, Optional from typing import Any, Optional
from util.logging_config import get_skill_logger from util.logging_config import get_skill_logger
PLATFORM_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
DEFAULT_CAPABILITIES_JSON = '{"web": true, "api_key": false, "rpa": true}'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Platform specification fields: # Platform specification fields:
# display_name Human-readable name (e.g. "Maersk") # display_name Human-readable name (e.g. "Maersk")
@@ -309,7 +315,7 @@ _PLATFORM_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str) -> Optional[str]: def resolve_platform_key(name: str) -> Optional[str]:
""" """
Resolve user input to a canonical platform key. Resolve user input to a canonical platform key (built-in PLATFORMS only).
Supports: key, display_name, any alias. Case-insensitive. Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized. Returns None if unrecognized.
""" """
@@ -330,8 +336,154 @@ def resolve_platform_key(name: str) -> Optional[str]:
return None return None
def is_valid_platform_key_format(name: str) -> bool:
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$"""
s = (name or "").strip().lower()
if not s:
return False
return bool(PLATFORM_KEY_PATTERN.match(s))
def _parse_aliases_json(val: Any) -> list[str]:
if val is None:
return []
try:
data = json.loads(val) if isinstance(val, str) else val
if isinstance(data, list):
return [str(a).strip() for a in data if str(a).strip()]
except (json.JSONDecodeError, TypeError, ValueError):
pass
return []
@dataclass(frozen=True)
class PlatformResolveResult:
platform_key: Optional[str] = None
error_code: Optional[str] = None
message: Optional[str] = None
@property
def ok(self) -> bool:
return bool(self.platform_key) and not self.error_code
def _resolve_platform_from_db(conn, raw: str) -> PlatformResolveResult:
"""在 platforms 表中解析:精确 keyenabled=1与 aliases_json。"""
raw_stripped = (raw or "").strip()
raw_lower = raw_stripped.lower()
if is_valid_platform_key_format(raw_stripped):
row = conn.execute(
"SELECT platform_key, enabled FROM platforms WHERE platform_key = ?",
(raw_lower,),
).fetchone()
if row:
if not row[1]:
return PlatformResolveResult(
error_code="PLATFORM_DISABLED",
message=f"平台 {row[0]} 已禁用。",
)
return PlatformResolveResult(platform_key=row[0])
rows = conn.execute(
"SELECT platform_key, enabled, aliases_json FROM platforms"
).fetchall()
for platform_key, enabled, aliases_json in rows:
if not enabled:
continue
names = {platform_key.lower()}
for alias in _parse_aliases_json(aliases_json):
names.add(alias.lower())
names.add(alias)
if raw_lower in names or raw_stripped in names:
return PlatformResolveResult(platform_key=platform_key)
return PlatformResolveResult()
def resolve_platform_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult:
"""
统一平台解析:内置 PLATFORMS → DB platforms 表 → 错误分类。
"""
raw = (input_name or "").strip()
if not raw:
return PlatformResolveResult(
error_code="INVALID_PLATFORM",
message="平台名不能为空。",
)
builtin = resolve_platform_key(raw)
if builtin:
return PlatformResolveResult(platform_key=builtin)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
db_result = _resolve_platform_from_db(conn, raw)
if db_result.ok or db_result.error_code:
return db_result
finally:
if close_conn:
conn.close()
if not is_valid_platform_key_format(raw):
return PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=(
f"平台 key 格式非法:「{raw}」。"
"建议使用小写字母、数字、下划线或连字符,例如 jiangchang。"
),
)
key_hint = raw.lower()
return PlatformResolveResult(
error_code="PLATFORM_NOT_REGISTERED",
message=(
f"平台 {key_hint} 尚未注册,请先执行 "
f"account platform ensure --key {key_hint} --display-name <名称> [--url <url>]"
),
)
def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]:
"""内置 PLATFORMS 优先结构;自定义平台从 DB platforms 表读取。"""
spec = PLATFORMS.get(platform_key)
if spec:
return dict(spec)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
from db.accounts_repo import get_platform_from_db
plat = get_platform_from_db(conn, platform_key)
if plat:
return {
"display_name": plat["display_name"],
"domain": plat.get("domain") or "generic",
"provider_code": plat.get("provider_code") or platform_key,
"default_url": plat.get("default_url") or "",
"aliases": plat.get("aliases") or [],
"capabilities": plat.get("capabilities") or {},
}
finally:
if close_conn:
conn.close()
return {}
def get_platform(key: str) -> Optional[dict[str, Any]]: def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated key.""" """Return the full platform spec dict for a validated built-in key."""
return PLATFORMS.get(key) return PLATFORMS.get(key)

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""在 Profile 目录内创建浏览器快捷方式Windows .lnk"""
from __future__ import annotations
import logging
import os
import re
import subprocess
import sys
from typing import Optional
from service.browser_service import resolve_chromium_executable
_WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def _lnk_safe_filename(name: str, fallback: str = "browser") -> str:
t = _WIN_LNK_FORBIDDEN.sub("_", (name or "").strip())
t = t.rstrip(" .")
return t or fallback
def _ps_single_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def create_profile_browser_shortcut(
profile_dir: str,
account_label: str,
log: Optional[logging.Logger] = None,
) -> Optional[str]:
"""
在 profile_dir 内创建 {account_label}.lnk目标为系统 Chrome/Edge + --user-data-dir。
非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。
成功返回 .lnk 绝对路径。
"""
if log is None:
log = logging.getLogger(__name__)
if sys.platform != "win32":
log.debug("profile_shortcut_skip platform=%s", sys.platform)
return None
browser_exe = resolve_chromium_executable()
if not browser_exe:
log.warning("profile_shortcut_skip reason=no_chromium")
return None
profile_abs = os.path.abspath(profile_dir)
os.makedirs(profile_abs, exist_ok=True)
lnk_name = f"{_lnk_safe_filename(account_label)}.lnk"
lnk_path = os.path.join(profile_abs, lnk_name)
arguments = f'--user-data-dir="{profile_abs}"'
working_dir = os.path.dirname(browser_exe)
ps = (
"$WshShell = New-Object -ComObject WScript.Shell; "
f"$s = $WshShell.CreateShortcut({_ps_single_quote(lnk_path)}); "
f"$s.TargetPath = {_ps_single_quote(browser_exe)}; "
f"$s.Arguments = {_ps_single_quote(arguments)}; "
f"$s.IconLocation = {_ps_single_quote(browser_exe + ',0')}; "
f"$s.WorkingDirectory = {_ps_single_quote(working_dir)}; "
"$s.Save()"
)
try:
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError) as exc:
log.warning("profile_shortcut_failed profile_dir=%s error=%s", profile_abs, exc)
return None
if not os.path.isfile(lnk_path):
log.warning("profile_shortcut_missing_after_create path=%s", lnk_path)
return None
log.info("profile_shortcut_created path=%s browser=%s", lnk_path, browser_exe)
return lnk_path

View File

@@ -0,0 +1,398 @@
# -*- coding: utf-8 -*-
"""自定义平台动态解析、platform ensure、add-web / pick-web / browser URL。"""
from __future__ import annotations
import json
import os
import pytest
JIANGCHANG_URL = "https://jc2009.com/admin/index.html"
def _ensure_jiangchang(clean_db) -> None:
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(
key="jiangchang",
display_name="匠厂后台",
domain="generic",
url=JIANGCHANG_URL,
auth_strategy=None,
aliases=json.dumps(["jc", "匠厂后台"], ensure_ascii=False),
capabilities=None,
)
assert rc == 0
class TestBuiltinPlatformsUnchanged:
def test_douyin_alias(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("抖音", conn=clean_db)
assert result.ok
assert result.platform_key == "douyin"
def test_toutiao_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("toutiao", conn=clean_db)
assert result.ok
assert result.platform_key == "toutiao"
class TestDynamicPlatformResolution:
def test_unregistered_valid_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("unknown_custom", conn=clean_db)
assert not result.ok
assert result.error_code == "PLATFORM_NOT_REGISTERED"
assert "platform ensure" in (result.message or "")
def test_invalid_platform_key_format(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("江厂 空格", conn=clean_db)
assert not result.ok
assert result.error_code == "INVALID_PLATFORM_KEY"
def test_empty_platform(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("", conn=clean_db)
assert result.error_code == "INVALID_PLATFORM"
def test_registered_jiangchang(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
_ensure_jiangchang(clean_db)
result = resolve_platform_key_dynamic("jiangchang", conn=clean_db)
assert result.ok
assert result.platform_key == "jiangchang"
def test_registered_alias_jc(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
_ensure_jiangchang(clean_db)
result = resolve_platform_key_dynamic("jc", conn=clean_db)
assert result.ok
assert result.platform_key == "jiangchang"
class TestPlatformEnsure:
def test_ensure_returns_json(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(
key="jiangchang",
display_name="匠厂后台",
url=JIANGCHANG_URL,
)
assert rc == 0
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["ok"] is True
assert payload["platform_key"] == "jiangchang"
assert payload["action"] in ("created", "updated")
class TestCustomPlatformAccounts:
def test_add_web_after_ensure(self, clean_db):
from service.account_service import cmd_account_add_web
from db.accounts_repo import find_accounts
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台 手工登录",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
accs = find_accounts(platform_key="jiangchang")
assert len(accs) == 1
assert accs[0]["platform_key"] == "jiangchang"
assert accs[0]["url"] == JIANGCHANG_URL
assert accs[0]["profile_dir"]
def test_pick_web_after_ensure(self, clean_db, capsys):
from service.account_service import cmd_account_add_web, cmd_account_pick_web
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
capsys.readouterr()
cmd_account_pick_web(
platform_input="jiangchang",
do_lease=True,
holder="test-holder",
ttl_sec=300,
)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["platform_key"] == "jiangchang"
assert payload["profile_dir"]
assert payload.get("lease_token")
def test_pick_web_unregistered_returns_structured_error(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web
cmd_account_pick_web(platform_input="unknown_custom")
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
assert "--platform-display-name" in payload["error"]["message"]
def test_add_web_unregistered_returns_structured_error(self, clean_db, capsys):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="unknown_custom",
login_id="manual",
login_id_type="manual",
label="x",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
class TestBrowserServiceUrlResolution:
def test_custom_platform_default_url_from_db(self, clean_db):
from db.accounts_repo import insert_account, upsert_platform
from service.browser_service import resolve_account_browser_url
import time
now = int(time.time())
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url=JIANGCHANG_URL,
)
aid = insert_account(
conn=clean_db,
platform_key="jiangchang",
account_label="test",
login_id=None,
login_id_type="manual",
tenant_id=None,
environment="production",
role="admin",
provider_code="jiangchang",
profile_dir="/tmp/test-profile",
url="",
extra_json="{}",
now=now,
auth_strategy="qr_code_manual",
)
clean_db.commit()
account = {
"id": aid,
"platform_key": "jiangchang",
"url": "",
}
assert resolve_account_browser_url(account, conn=clean_db) == JIANGCHANG_URL
def test_account_url_overrides_platform_default(self, clean_db):
from service.browser_service import resolve_account_browser_url
account = {
"platform_key": "jiangchang",
"url": "https://custom.example.com/admin",
}
assert (
resolve_account_browser_url(account, conn=clean_db)
== "https://custom.example.com/admin"
)
class TestPlatformKeyFormatValidation:
def test_ensure_rejects_invalid_key(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(key="江厂 空格", display_name="bad")
assert rc == 1
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY"
JIANGCHANG_META_KWARGS = dict(
display_name="匠厂后台",
url=JIANGCHANG_URL,
domain="jc2009.com",
auth_strategy="qr_code_manual",
aliases=["jiangchang", "匠厂", "匠厂后台", "jc2009"],
)
class TestAutoRegisterFromCallerMeta:
def _meta(self):
from service.account_service import PlatformCallerMeta
return PlatformCallerMeta(**JIANGCHANG_META_KWARGS)
def test_pick_web_existing_platform_unchanged(self, clean_db, capsys):
from service.account_service import cmd_account_add_web, cmd_account_pick_web
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
capsys.readouterr()
cmd_account_pick_web(platform_input="jiangchang", do_lease=True, holder="h1")
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["platform_key"] == "jiangchang"
assert payload.get("profile_dir")
def test_pick_web_unregistered_without_meta(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web
cmd_account_pick_web(platform_input="auto_reg_plat")
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
assert "--platform-display-name" in payload["error"]["message"]
def test_pick_web_unregistered_with_meta_auto_registers(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
from util.platforms import resolve_platform_key_dynamic
meta = PlatformCallerMeta(
display_name="自动注册测试",
url="https://example.com/admin",
domain="example.com",
auth_strategy="qr_code_manual",
aliases=["auto_reg_plat"],
)
cmd_account_pick_web(platform_input="auto_reg_plat", platform_meta=meta)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
result = resolve_platform_key_dynamic("auto_reg_plat", conn=clean_db)
assert result.ok
assert result.platform_key == "auto_reg_plat"
def test_add_web_unregistered_with_meta_auto_registers(self, clean_db):
from service.account_service import cmd_account_add_web, PlatformCallerMeta
from db.accounts_repo import find_accounts
meta = PlatformCallerMeta(
display_name="匠厂后台",
url=JIANGCHANG_URL,
domain="jc2009.com",
auth_strategy="qr_code_manual",
aliases=["jiangchang"],
)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
platform_meta=meta,
)
accs = find_accounts(platform_key="jiangchang")
assert len(accs) == 1
assert accs[0]["url"] == JIANGCHANG_URL
def test_invalid_platform_key_no_register(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
meta = PlatformCallerMeta(display_name="", url="https://x.com")
cmd_account_pick_web(platform_input="bad key!", platform_meta=meta)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY"
row = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key LIKE '%bad%'"
).fetchone()[0]
assert row == 0
def test_disabled_platform_not_re_enabled(self, clean_db, capsys):
from db.accounts_repo import upsert_platform
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url=JIANGCHANG_URL,
)
clean_db.execute("UPDATE platforms SET enabled = 0 WHERE platform_key = 'jiangchang'")
clean_db.commit()
meta = PlatformCallerMeta(display_name="匠厂后台", url=JIANGCHANG_URL)
cmd_account_pick_web(platform_input="jiangchang", platform_meta=meta)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:PLATFORM_DISABLED"
enabled = clean_db.execute(
"SELECT enabled FROM platforms WHERE platform_key = 'jiangchang'"
).fetchone()[0]
assert enabled == 0
def test_auto_register_idempotent(self, clean_db):
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
meta = PlatformCallerMeta(
display_name="幂等测试",
url="https://example.com/admin",
aliases=["idem_plat"],
)
cmd_account_pick_web(platform_input="idem_plat", platform_meta=meta)
cmd_account_pick_web(platform_input="idem_plat", platform_meta=meta)
count = clean_db.execute(
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'idem_plat'"
).fetchone()[0]
assert count == 1

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""resolve_platform_key_dynamic 与 JSON 解析边界。"""
from __future__ import annotations
import json
import pytest
class TestResolvePlatformKeyDynamicParsing:
def test_builtin_still_works_without_db_row(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("马士基", conn=clean_db)
assert result.platform_key == "maersk"
def test_disabled_platform_returns_disabled(self, clean_db):
from db.accounts_repo import upsert_platform
from util.platforms import resolve_platform_key_dynamic
upsert_platform(
clean_db,
key="custom_off",
display_name="Off",
default_url="",
)
clean_db.execute(
"UPDATE platforms SET enabled = 0 WHERE platform_key = ?",
("custom_off",),
)
clean_db.commit()
result = resolve_platform_key_dynamic("custom_off", conn=clean_db)
assert result.error_code == "PLATFORM_DISABLED"
def test_get_platform_spec_from_db(self, clean_db):
from db.accounts_repo import upsert_platform
from util.platforms import get_platform_spec
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url="https://jc2009.com/admin/index.html",
)
spec = get_platform_spec("jiangchang", conn=clean_db)
assert spec["display_name"] == "匠厂后台"
assert spec["default_url"] == "https://jc2009.com/admin/index.html"
def test_malformed_aliases_json_does_not_crash(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
import time
now = int(time.time())
clean_db.execute(
"""
INSERT INTO platforms (
platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, default_auth_strategy, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, NULL, ?, ?)
""",
(
"bad_alias",
"Bad",
"generic",
"bad_alias",
"",
"not-json",
"{}",
now,
now,
),
)
clean_db.commit()
result = resolve_platform_key_dynamic("bad_alias", conn=clean_db)
assert result.platform_key == "bad_alias"

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""Profile 目录浏览器快捷方式测试。"""
import os
import sys
import pytest
from util.profile_shortcut import _lnk_safe_filename, create_profile_browser_shortcut
class TestLnkSafeFilename:
def test_strips_forbidden_chars(self) -> None:
assert _lnk_safe_filename('小红书/测试:账号') == "小红书_测试_账号"
def test_empty_uses_fallback(self) -> None:
assert _lnk_safe_filename(" ") == "browser"
class TestCreateProfileBrowserShortcut:
def test_skips_non_windows(self, tmp_path, monkeypatch) -> None:
monkeypatch.setattr("util.profile_shortcut.sys.platform", "linux")
out = create_profile_browser_shortcut(str(tmp_path), "demo")
assert out is None
def test_skips_when_no_browser(self, tmp_path, monkeypatch) -> None:
monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32")
monkeypatch.setattr(
"util.profile_shortcut.resolve_chromium_executable",
lambda: None,
)
out = create_profile_browser_shortcut(str(tmp_path), "demo")
assert out is None
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None:
from service.browser_service import resolve_chromium_executable
browser_exe = resolve_chromium_executable()
if not browser_exe:
pytest.skip("no Chrome/Edge installed")
label = "ShortcutTest"
out = create_profile_browser_shortcut(str(tmp_path), label)
assert out is not None
assert os.path.isfile(out)
assert out.endswith(f"{label}.lnk")
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
def test_add_web_creates_shortcut(self, clean_db, fake_env, monkeypatch) -> None:
from service.browser_service import resolve_chromium_executable
from service.account_service import cmd_account_add_web
from db.accounts_repo import find_accounts
if not resolve_chromium_executable():
pytest.skip("no Chrome/Edge installed")
label = "Maersk shortcut test"
cmd_account_add_web(
platform_input="maersk",
login_id="shortcut@test.com",
login_id_type="email",
label=label,
tenant_id="tenant-test",
environment="production",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
accs = find_accounts(platform_key="maersk")
assert len(accs) == 1
profile_dir = accs[0]["profile_dir"]
lnk_path = os.path.join(profile_dir, f"{label}.lnk")
assert os.path.isfile(lnk_path), f"expected shortcut at {lnk_path}"