Compare commits

..

4 Commits

Author SHA1 Message Date
e441b0c58e feat: account ensure-web (pick-or-create for first-run UX)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-18 14:37:16 +08:00
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
15 changed files with 660 additions and 24 deletions

View File

@@ -1,7 +1,7 @@
---
name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token支持 9 种登录策略账密自动、扫码、2FA、SSO 等提供加密本地存储Fernet提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.0
version: 2.0.8
author: 深圳匠厂科技有限公司
metadata:
openclaw:
@@ -30,7 +30,7 @@ allowed-tools:
1. **登记网页/RPA 账号**`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`
2. **登记凭据API Key / Token / 加密密码)**`account add-secret``none` / `env` / `windows_credential` / `local_encrypted`
3. **查看/列出账号**`account get/list` — JSON 输出。
4. **为 RPA 挑选账号**`account pick-web` — 按环境/租户/角色筛选,可带 lease。
4. **为 RPA 挑选账号**`account pick-web` — 按环境/租户/角色筛选,可带 lease;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则按调用方参数自动 `add-web` 再返回
5. **获取 API Key凭据表**`credential pick` — 默认隐藏 secret`--reveal` 时才返回。
6. **浏览器打开查看**`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
7. **管理 lease**`lease release/list/cleanup` — 防止 RPA 并发。
@@ -38,6 +38,7 @@ allowed-tools:
9. **加密备份与迁移**`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
10. **按 lease 取明文密码**`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
11. **业务 skill 同进程登录**`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
12. **有则用、无则建(网页账号)**`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
## CLI 调用

View File

@@ -42,7 +42,8 @@
| `account get <id> [--with-credentials]` | 输出账号 JSON若带 `--with-credentials` 则附加关联凭据摘要) |
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>] [--limit N]` | 输出账号 JSON **数组** |
| `account pick-web --platform <p> [--environment <env>] [--tenant-id <t>] [--role <role>] [--lease] [--ttl-sec 900] [--holder <h>] [--purpose <purpose>]` | 挑选网页自动化候选;返回 JSON 含 `auth_strategy` / `session_persistent` / `device_fingerprint` |
| `account pick-web --platform <p> [...] [--lease] [...] [--ensure]` | 挑选网页自动化候选;`--ensure` 等同 `ensure-web`(零账号时自动登记) |
| `account ensure-web --platform <p> [--login-id <id>] [--label <label>] [...] [--lease]` | **有则 pick、无则 add-web 再 pick**;有账号但均被租约占用时**不新建**,返回 `ERROR:LEASE_CONFLICT`;成功时结构同 `pick-web`,若本次新建则带 `account_ensured: true` |
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 |

View File

@@ -51,6 +51,7 @@
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
| `ERROR:LEASE_CONFLICT` | `ensure-web` / `pick-web --ensure`:已有账号但均被租约占用(**不会**自动再建);或同账号重复 acquire lease |
---

View File

@@ -11,7 +11,9 @@
| **A. CLI subprocessv1 起)** | 任意业务 skill一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
| **B. rpa_helpers 同进程 importv2** | 需要 Playwright 上下文内完成登录与页面操作 | `sys.path.insert(0, f"{account_manager_root}/scripts"); from service.rpa_helpers import ensure_logged_in` |
常见组合:**CLI** 负责 pick / lease / `get-credential --reveal`**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
常见组合:**CLI** 负责 `ensure-web`(或 `pick-web --ensure`/ lease / `get-credential --reveal`**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
业务技能首启推荐:`account ensure-web --platform <p> [--label ...] [--lease --holder <skill>]`,避免因库中尚无账号而 `NO_ACCOUNT`。登记账号 ≠ 站点已登录。
## 推荐调用方式
@@ -26,6 +28,7 @@
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
| 有则用无则建 | `account ensure-web ...` / `pick-web --ensure` | **同 pick-web**;本次新建时多 `account_ensured: true` |
| 取加密凭据明文 | `account get-credential <id> --reveal --lease-token <t>` | **单行 JSON**,含 `plaintext`(若可解密) |
| 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**stderr 有警告行) |
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr |

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

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ PyArmor trial per-file line limits. This module re-exports the public API.
"""
from service._svc_common import PlatformCallerMeta
from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web
from service.account_ensure_commands import cmd_account_ensure_web
from service.account_mark_commands import (
cmd_account_mark_error,
cmd_account_mark_session,
@@ -43,6 +44,7 @@ __all__ = [
"PlatformCallerMeta",
"cmd_account_add_secret",
"cmd_account_add_web",
"cmd_account_ensure_web",
"cmd_account_get",
"cmd_account_get_credential",
"cmd_account_list",

View File

@@ -33,13 +33,12 @@ def _win_find_exe(candidates):
return None
def resolve_chromium_channel():
def resolve_chromium_executable():
"""
Playwright channel: 'chrome' | 'msedge' | None
Windows 优先 Chrome其次 Edge其它系统默认 chrome由 Playwright 解析 PATH
Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None
"""
if sys.platform != "win32":
return "chrome"
return None
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
@@ -53,7 +52,7 @@ def resolve_chromium_channel():
]
)
if chrome:
return "chrome"
return chrome
edge = _win_find_exe(
[
@@ -63,11 +62,27 @@ def resolve_chromium_channel():
]
)
if edge:
return "msedge"
return edge
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():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge请先安装")
print(" • Chrome: https://www.google.com/chrome/")

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

143
tests/test_ensure_web.py Normal file
View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""Tests for account ensure-web (pick-or-create)."""
from __future__ import annotations
import io
import json
import sys
import pytest
def _capture(fn, *args, **kwargs):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
fn(*args, **kwargs)
finally:
sys.stdout = old
return buf.getvalue()
class TestEnsureWeb:
def test_ensure_creates_when_empty(self, clean_db):
from service.account_service import cmd_account_ensure_web
raw = _capture(
cmd_account_ensure_web,
"maersk",
label="马士基默认",
)
result = json.loads(raw)
assert result.get("success") is not False
assert result["platform_key"] == "maersk"
assert result.get("profile_dir")
assert result.get("account_ensured") is True
assert result.get("id")
def test_ensure_reuses_existing_no_duplicate(self, clean_db):
from service.account_service import cmd_account_add_web, cmd_account_ensure_web
from db.accounts_repo import find_accounts
_capture(
cmd_account_add_web,
"maersk",
"a@test.com",
"email",
"已有",
None,
"production",
"default",
None,
None,
None,
None,
)
before = find_accounts(platform_key="maersk", status="active", limit=50)
assert len(before) == 1
raw = _capture(cmd_account_ensure_web, "maersk")
result = json.loads(raw)
assert result.get("account_ensured") is not True
assert result["id"] == before[0]["id"]
after = find_accounts(platform_key="maersk", status="active", limit=50)
assert len(after) == 1
def test_ensure_lease_busy_does_not_create(self, clean_db):
from db.accounts_repo import acquire_lease, find_account_ids, find_accounts
from service.account_service import cmd_account_add_web, cmd_account_ensure_web
_capture(
cmd_account_add_web,
"cma_cgm",
"b@cma.com",
"email",
"CMA",
None,
"production",
"default",
None,
None,
None,
None,
)
ids = find_account_ids("cma_cgm")
assert len(ids) == 1
acquire_lease(
account_id=ids[0],
lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
holder="other",
purpose="busy",
ttl_sec=900,
)
assert find_account_ids("cma_cgm") == []
raw = _capture(cmd_account_ensure_web, "cma_cgm", do_lease=True, holder="me")
err = json.loads(raw)
assert err["success"] is False
assert err["error"]["code"] == "ERROR:LEASE_CONFLICT"
still = find_accounts(platform_key="cma_cgm", status="active", limit=50)
assert len(still) == 1
def test_pick_web_ensure_flag_cli(self, clean_db, fake_env):
"""CLI account pick-web --ensure creates when empty."""
import os
import subprocess
scripts = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"
)
env = {
**os.environ,
"PYTHONPATH": scripts,
"JIANGCHANG_DATA_ROOT": fake_env["data_root"],
"JIANGCHANG_USER_ID": fake_env["user_id"],
}
r = subprocess.run(
[
sys.executable,
"-m",
"cli.app",
"account",
"pick-web",
"--platform",
"maersk",
"--ensure",
"--label",
"cli-default",
],
capture_output=True,
text=True,
env=env,
cwd=scripts,
)
assert r.returncode == 0, r.stderr + r.stdout
# last non-empty JSON line
lines = [ln for ln in r.stdout.splitlines() if ln.strip()]
result = json.loads(lines[-1])
assert result.get("platform_key") == "maersk"
assert result.get("account_ensured") is True
assert result.get("profile_dir")

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}"

View File

@@ -27,7 +27,9 @@ def test_usage_includes_all_account_subcommands():
"export-credentials",
"import-credentials",
"add-web",
"ensure-web",
"get-credential",
"pick-web",
):
assert cmd in usage, f"USAGE 缺少 {cmd}"
assert "--ensure" in usage