Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44eafff757 | |||
| 3ce0cc40a1 |
17
CHANGELOG.md
Normal file
17
CHANGELOG.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
本文件对应技能市场 **「更新日志」** Tab。
|
||||||
|
发布 CI 会按当前 tag 版本提取匹配的 `##` 小节。
|
||||||
|
|
||||||
|
## 2.0.26
|
||||||
|
|
||||||
|
- 「打开浏览器」启动前写入 Profile「继续上次会话」,减轻独立 Profile 下 Session Cookie 关窗后丢失(科力普等站点更明显)
|
||||||
|
|
||||||
|
## 2.0.25
|
||||||
|
|
||||||
|
- 「账号租约」表新增行右键「强制释放租约」:仅状态为 active 时显示,确认后立刻释放,无需租约令牌
|
||||||
|
- 同步提供 CLI:`lease force-release --id <租约编号>`
|
||||||
|
|
||||||
|
## 2.0.24
|
||||||
|
|
||||||
|
- (历史版本,见 git tag)
|
||||||
4
SKILL.md
4
SKILL.md
@@ -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.24
|
version: 2.0.26
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
@@ -39,7 +39,7 @@ allowed-tools:
|
|||||||
10. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
10. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||||
11. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
|
11. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
|
||||||
12. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
|
12. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
|
||||||
13. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
13. **管理 lease**:`lease release/force-release/list/cleanup` — 防止 RPA 并发;数据管理「账号租约」行右键可强制释放。
|
||||||
14. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
14. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
||||||
15. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
15. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
||||||
16. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
16. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
||||||
|
|||||||
@@ -248,6 +248,44 @@
|
|||||||
"command": "account",
|
"command": "account",
|
||||||
"args": ["open", "{{accountId}}"]
|
"args": ["open", "{{accountId}}"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lease-force-release",
|
||||||
|
"label": "强制释放租约",
|
||||||
|
"description": "强制释放这条活跃租约,让账号立刻可被其它任务占用。仅状态为 active 时可用。",
|
||||||
|
"placements": ["row"],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "confirm",
|
||||||
|
"confirmation": {
|
||||||
|
"message": "强制释放后,其它任务可立刻占用该账号。若原任务仍在跑,可能冲突。是否继续?"
|
||||||
|
},
|
||||||
|
"bind": {
|
||||||
|
"tables": ["account_leases"],
|
||||||
|
"inputMapping": {
|
||||||
|
"leaseId": "$row.$pk"
|
||||||
|
},
|
||||||
|
"visibleWhen": {
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["leaseId"],
|
||||||
|
"properties": {
|
||||||
|
"leaseId": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "租约编号",
|
||||||
|
"description": "当前行编号,一般无需修改",
|
||||||
|
"readOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "lease",
|
||||||
|
"args": ["force-release", "--id", "{{leaseId}}"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
| `credential pick --platform <p> [--type <type>] [--environment <env>] [--role <role>] [--reveal]` | v1 兼容:挑选凭据 |
|
| `credential pick --platform <p> [--type <type>] [--environment <env>] [--role <role>] [--reveal]` | v1 兼容:挑选凭据 |
|
||||||
| `credential resolve --id <credential_id> [--reveal]` | v1 兼容:按凭据 id 解析 |
|
| `credential resolve --id <credential_id> [--reveal]` | v1 兼容:按凭据 id 解析 |
|
||||||
| `lease release <lease_token>` | 释放租约 |
|
| `lease release <lease_token>` | 释放租约 |
|
||||||
|
| `lease force-release --id <租约编号>` | 按行编号强制释放活跃租约(数据管理右键) |
|
||||||
| `lease list [--active]` | 列出租约 |
|
| `lease list [--active]` | 列出租约 |
|
||||||
| `lease cleanup` | 清理过期租约 |
|
| `lease cleanup` | 清理过期租约 |
|
||||||
| `account open <id>` / `open <id>` | 打开浏览器 |
|
| `account open <id>` / `open <id>` | 打开浏览器 |
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
| 前缀 | 含义 |
|
| 前缀 | 含义 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
|
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
|
||||||
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 |
|
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约(含 force-release 时行不存在或已非 active) |
|
||||||
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
|
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -59,4 +59,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.24
|
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.26
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Subcommand tree:
|
|||||||
account add-web|copy|update|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open|reveal-profile
|
account add-web|copy|update|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open|reveal-profile
|
||||||
init-master-key|export-credentials|import-credentials
|
init-master-key|export-credentials|import-credentials
|
||||||
credential pick|resolve
|
credential pick|resolve
|
||||||
lease release|list|cleanup
|
lease release|force-release|list|cleanup
|
||||||
(legacy) add|list|get|list-json|pick-web|open|delete
|
(legacy) add|list|get|list-json|pick-web|open|delete
|
||||||
|
|
||||||
Machine commands output single-line JSON where appropriate.
|
Machine commands output single-line JSON where appropriate.
|
||||||
@@ -42,6 +42,7 @@ from service.account_service import (
|
|||||||
cmd_get,
|
cmd_get,
|
||||||
cmd_init_db,
|
cmd_init_db,
|
||||||
cmd_lease_cleanup,
|
cmd_lease_cleanup,
|
||||||
|
cmd_lease_force_release,
|
||||||
cmd_lease_list,
|
cmd_lease_list,
|
||||||
cmd_lease_release,
|
cmd_lease_release,
|
||||||
cmd_list,
|
cmd_list,
|
||||||
@@ -121,6 +122,7 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
|||||||
|
|
||||||
租约管理:
|
租约管理:
|
||||||
lease release <lease_token>
|
lease release <lease_token>
|
||||||
|
lease force-release --id <租约编号>
|
||||||
lease list [--active]
|
lease list [--active]
|
||||||
lease cleanup
|
lease cleanup
|
||||||
|
|
||||||
@@ -369,7 +371,7 @@ def main() -> None:
|
|||||||
# ---- lease subcommand ----
|
# ---- lease subcommand ----
|
||||||
elif cmd == "lease":
|
elif cmd == "lease":
|
||||||
if len(av) < 3:
|
if len(av) < 3:
|
||||||
print("ERROR:CLI_BAD_ARGS lease 需要子命令:release | list | cleanup")
|
print("ERROR:CLI_BAD_ARGS lease 需要子命令:release | force-release | list | cleanup")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sub = av[2]
|
sub = av[2]
|
||||||
if sub == "release":
|
if sub == "release":
|
||||||
@@ -377,13 +379,25 @@ def main() -> None:
|
|||||||
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
|
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cmd_lease_release(av[3])
|
cmd_lease_release(av[3])
|
||||||
|
elif sub == "force-release":
|
||||||
|
lease_id = _get_opt(av, "--id")
|
||||||
|
if lease_id is None and len(av) >= 4 and not str(av[3]).startswith("-"):
|
||||||
|
lease_id = av[3]
|
||||||
|
if lease_id is None:
|
||||||
|
print("ERROR:CLI_BAD_ARGS lease force-release 需要 --id <租约编号>。")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
cmd_lease_force_release(int(lease_id))
|
||||||
|
except ValueError:
|
||||||
|
print("ERROR:CLI_BAD_ARGS 租约编号必须是数字。")
|
||||||
|
sys.exit(1)
|
||||||
elif sub == "list":
|
elif sub == "list":
|
||||||
active = _has_flag(av, "--active")
|
active = _has_flag(av, "--active")
|
||||||
cmd_lease_list(active)
|
cmd_lease_list(active)
|
||||||
elif sub == "cleanup":
|
elif sub == "cleanup":
|
||||||
cmd_lease_cleanup()
|
cmd_lease_cleanup()
|
||||||
else:
|
else:
|
||||||
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
|
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、force-release、list 或 cleanup。")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# ---- Legacy aliases ----
|
# ---- Legacy aliases ----
|
||||||
|
|||||||
@@ -795,6 +795,59 @@ def release_lease(lease_token: str) -> bool:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def release_lease_by_id(lease_id: int) -> Optional[dict]:
|
||||||
|
"""Force-release an active lease by row id. Returns released row summary or None."""
|
||||||
|
log = get_skill_logger()
|
||||||
|
log.info("lease_force_release_attempt lease_id=%s", lease_id)
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
now = _now_unix()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, account_id, lease_token, holder, purpose, status "
|
||||||
|
"FROM account_leases WHERE id = ?",
|
||||||
|
(int(lease_id),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
log.warning("lease_force_release_not_found lease_id=%s", lease_id)
|
||||||
|
return None
|
||||||
|
status = str(row[5] or "")
|
||||||
|
if status != "active":
|
||||||
|
log.warning(
|
||||||
|
"lease_force_release_not_active lease_id=%s status=%s",
|
||||||
|
lease_id,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? "
|
||||||
|
"WHERE id = ? AND status = 'active'",
|
||||||
|
(now, now, int(lease_id)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return None
|
||||||
|
token = str(row[2] or "")
|
||||||
|
log.info(
|
||||||
|
"lease_force_release_success lease_id=%s account_id=%s token_prefix=%s",
|
||||||
|
lease_id,
|
||||||
|
row[1],
|
||||||
|
token[:8] if token else "",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": int(row[0]),
|
||||||
|
"account_id": int(row[1]),
|
||||||
|
"lease_token": token,
|
||||||
|
"holder": row[3] or "",
|
||||||
|
"purpose": row[4] or "",
|
||||||
|
"status": "released",
|
||||||
|
"released_at": _unix_to_iso_local(now),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def list_active_leases() -> list[dict]:
|
def list_active_leases() -> list[dict]:
|
||||||
"""List all active (non-expired, non-released) leases."""
|
"""List all active (non-expired, non-released) leases."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ from service.legacy_commands import (
|
|||||||
cmd_delete_by_platform_phone,
|
cmd_delete_by_platform_phone,
|
||||||
cmd_list,
|
cmd_list,
|
||||||
)
|
)
|
||||||
from service.lease_commands import cmd_lease_cleanup, cmd_lease_list, cmd_lease_release
|
from service.lease_commands import (
|
||||||
|
cmd_lease_cleanup,
|
||||||
|
cmd_lease_force_release,
|
||||||
|
cmd_lease_list,
|
||||||
|
cmd_lease_release,
|
||||||
|
)
|
||||||
from service.platform_commands import (
|
from service.platform_commands import (
|
||||||
cmd_init_db,
|
cmd_init_db,
|
||||||
cmd_platform_ensure,
|
cmd_platform_ensure,
|
||||||
@@ -70,6 +75,7 @@ __all__ = [
|
|||||||
"cmd_get",
|
"cmd_get",
|
||||||
"cmd_init_db",
|
"cmd_init_db",
|
||||||
"cmd_lease_cleanup",
|
"cmd_lease_cleanup",
|
||||||
|
"cmd_lease_force_release",
|
||||||
"cmd_lease_list",
|
"cmd_lease_list",
|
||||||
"cmd_lease_release",
|
"cmd_lease_release",
|
||||||
"cmd_list",
|
"cmd_list",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sys
|
|||||||
|
|
||||||
from db.connection import get_conn, init_db
|
from db.connection import get_conn, init_db
|
||||||
from db.accounts_repo import get_account_by_id, get_platform_from_db
|
from db.accounts_repo import get_account_by_id, get_platform_from_db
|
||||||
|
from service.chrome_profile_prefs import ensure_session_restore
|
||||||
from util.logging_config import get_skill_logger
|
from util.logging_config import get_skill_logger
|
||||||
from util.platforms import PLATFORMS
|
from util.platforms import PLATFORMS
|
||||||
|
|
||||||
@@ -225,6 +226,10 @@ def cmd_open(account_id):
|
|||||||
profile_abs = os.path.abspath(profile_dir)
|
profile_abs = os.path.abspath(profile_dir)
|
||||||
os.makedirs(profile_abs, exist_ok=True)
|
os.makedirs(profile_abs, exist_ok=True)
|
||||||
|
|
||||||
|
# 独立 Profile 默认不会「继续上次会话」,Session Cookie 关窗即丢;打开前写入 Preferences
|
||||||
|
prefs_result = ensure_session_restore(profile_abs)
|
||||||
|
log.info("browser_open_prefs %s", prefs_result)
|
||||||
|
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
url = resolve_account_browser_url(target, conn=conn)
|
url = resolve_account_browser_url(target, conn=conn)
|
||||||
@@ -235,11 +240,12 @@ def cmd_open(account_id):
|
|||||||
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)]
|
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)]
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"browser_open_launch exe=%s profile_dir=%s url=%s platform=%s",
|
"browser_open_launch exe=%s profile_dir=%s url=%s platform=%s restore_on_startup=%s",
|
||||||
browser_exe,
|
browser_exe,
|
||||||
profile_abs,
|
profile_abs,
|
||||||
url,
|
url,
|
||||||
target.get("platform_key"),
|
target.get("platform_key"),
|
||||||
|
prefs_result.get("restore_after"),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -289,6 +295,7 @@ def cmd_open(account_id):
|
|||||||
"profile_dir": profile_abs,
|
"profile_dir": profile_abs,
|
||||||
"url": url,
|
"url": url,
|
||||||
"pid": child.pid,
|
"pid": child.pid,
|
||||||
|
"session_restore_on_startup": prefs_result.get("restore_after"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
|
|||||||
80
scripts/service/chrome_profile_prefs.py
Normal file
80
scripts/service/chrome_profile_prefs.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Chrome/Edge 独立 Profile:启用「继续上次会话」,让 Session Cookie 可跨次打开保留。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RESTORE_CONTINUE_WHERE_LEFT_OFF = 1
|
||||||
|
|
||||||
|
|
||||||
|
def preferences_path(profile_dir: str) -> str:
|
||||||
|
return os.path.join(os.path.abspath(profile_dir), "Default", "Preferences")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_session_restore(profile_dir: str) -> Dict[str, Any]:
|
||||||
|
"""启动浏览器前写入 session.restore_on_startup=1;Crashed → Normal。"""
|
||||||
|
profile_abs = os.path.abspath(profile_dir)
|
||||||
|
prefs = preferences_path(profile_abs)
|
||||||
|
os.makedirs(os.path.dirname(prefs), exist_ok=True)
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
existed = os.path.isfile(prefs)
|
||||||
|
if existed:
|
||||||
|
try:
|
||||||
|
with open(prefs, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("chrome_prefs_read_failed path=%s", prefs)
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
before = int((data.get("session") or {}).get("restore_on_startup") or 0)
|
||||||
|
exit_before = str((data.get("profile") or {}).get("exit_type") or "")
|
||||||
|
|
||||||
|
session = data.setdefault("session", {})
|
||||||
|
session["restore_on_startup"] = RESTORE_CONTINUE_WHERE_LEFT_OFF
|
||||||
|
|
||||||
|
profile = data.setdefault("profile", {})
|
||||||
|
if str(profile.get("exit_type") or "").lower() == "crashed":
|
||||||
|
profile["exit_type"] = "Normal"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(prefs, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("chrome_prefs_write_failed path=%s err=%s", prefs, exc)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"path": prefs,
|
||||||
|
"existed": existed,
|
||||||
|
"restore_before": before,
|
||||||
|
"restore_after": before,
|
||||||
|
"exit_type_before": exit_before,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
after = int((data.get("session") or {}).get("restore_on_startup") or 0)
|
||||||
|
exit_after = str((data.get("profile") or {}).get("exit_type") or "")
|
||||||
|
logger.info(
|
||||||
|
"chrome_session_restore_ensured profile_dir=%s prefs=%s existed=%s "
|
||||||
|
"restore_on_startup %s->%s exit_type %s->%s",
|
||||||
|
profile_abs,
|
||||||
|
prefs,
|
||||||
|
existed,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
exit_before or "-",
|
||||||
|
exit_after or "-",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"path": prefs,
|
||||||
|
"existed": existed,
|
||||||
|
"restore_before": before,
|
||||||
|
"restore_after": after,
|
||||||
|
"exit_type_before": exit_before,
|
||||||
|
"exit_type_after": exit_after,
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Lease management CLI commands."""
|
"""Lease management CLI commands."""
|
||||||
from db.accounts_repo import cleanup_expired_leases, list_active_leases, release_lease
|
from db.accounts_repo import (
|
||||||
|
cleanup_expired_leases,
|
||||||
|
list_active_leases,
|
||||||
|
release_lease,
|
||||||
|
release_lease_by_id,
|
||||||
|
)
|
||||||
from service._svc_common import _err_json, _ok_json, _say
|
from service._svc_common import _err_json, _ok_json, _say
|
||||||
from util.logging_config import get_skill_logger
|
from util.logging_config import get_skill_logger
|
||||||
|
|
||||||
@@ -16,6 +21,41 @@ def cmd_lease_release(lease_token: str) -> None:
|
|||||||
_say(_err_json("ERROR:LEASE_NOT_FOUND", "未找到活跃的租约,或租约已释放/过期。"))
|
_say(_err_json("ERROR:LEASE_NOT_FOUND", "未找到活跃的租约,或租约已释放/过期。"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_lease_force_release(lease_id: int) -> None:
|
||||||
|
"""lease force-release --id <lease_row_id> — data-management row action."""
|
||||||
|
log = get_skill_logger()
|
||||||
|
log.info("cli_start subcommand=lease_force_release lease_id=%s", lease_id)
|
||||||
|
try:
|
||||||
|
lid = int(lease_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
_say(_err_json("ERROR:CLI_BAD_ARGS", "租约编号必须是数字。"))
|
||||||
|
return
|
||||||
|
if lid <= 0:
|
||||||
|
_say(_err_json("ERROR:CLI_BAD_ARGS", "租约编号必须是正整数。"))
|
||||||
|
return
|
||||||
|
released = release_lease_by_id(lid)
|
||||||
|
if not released:
|
||||||
|
_say(
|
||||||
|
_err_json(
|
||||||
|
"ERROR:LEASE_NOT_FOUND",
|
||||||
|
"未找到可释放的活跃租约(可能已释放、已过期清理,或不存在)。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_say(
|
||||||
|
_ok_json(
|
||||||
|
{
|
||||||
|
"id": released["id"],
|
||||||
|
"account_id": released["account_id"],
|
||||||
|
"holder": released.get("holder") or "",
|
||||||
|
"purpose": released.get("purpose") or "",
|
||||||
|
"status": "released",
|
||||||
|
"message": "已强制释放租约,该账号可被其它任务占用。",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cmd_lease_list(active_only: bool = False) -> None:
|
def cmd_lease_list(active_only: bool = False) -> None:
|
||||||
"""lease list [--active]"""
|
"""lease list [--active]"""
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
|
|||||||
@@ -144,6 +144,67 @@ class TestLeaseRelease:
|
|||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
|
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
|
||||||
|
|
||||||
|
def test_force_release_by_id(self, clean_db):
|
||||||
|
"""force-release by lease row id frees the account for pick-web."""
|
||||||
|
from db.accounts_repo import acquire_lease, find_account_ids, release_lease_by_id
|
||||||
|
from service.account_service import cmd_account_add_web, cmd_lease_force_release
|
||||||
|
import io, sys
|
||||||
|
|
||||||
|
cmd_account_add_web(
|
||||||
|
"maersk", "force-release@maersk.com", "email", "FR", "t1", "production", "booking",
|
||||||
|
None, None, None, None,
|
||||||
|
)
|
||||||
|
ids = find_account_ids("maersk")
|
||||||
|
acc_id = ids[0]
|
||||||
|
lease = acquire_lease(
|
||||||
|
account_id=acc_id,
|
||||||
|
lease_token="f1e2d3c4b5a697887766554433221100",
|
||||||
|
holder="stuck-holder",
|
||||||
|
purpose="download",
|
||||||
|
ttl_sec=900,
|
||||||
|
)
|
||||||
|
assert find_account_ids("maersk") == []
|
||||||
|
|
||||||
|
released = release_lease_by_id(int(lease["id"]))
|
||||||
|
assert released is not None
|
||||||
|
assert released["status"] == "released"
|
||||||
|
assert acc_id in find_account_ids("maersk")
|
||||||
|
|
||||||
|
# Re-acquire and exercise CLI command output shape
|
||||||
|
lease2 = acquire_lease(
|
||||||
|
account_id=acc_id,
|
||||||
|
lease_token="a0b1c2d3e4f5061728394a5b6c7d8e9f",
|
||||||
|
holder="stuck-2",
|
||||||
|
purpose="download",
|
||||||
|
ttl_sec=900,
|
||||||
|
)
|
||||||
|
buf = io.StringIO()
|
||||||
|
old = sys.stdout
|
||||||
|
sys.stdout = buf
|
||||||
|
try:
|
||||||
|
cmd_lease_force_release(int(lease2["id"]))
|
||||||
|
finally:
|
||||||
|
sys.stdout = old
|
||||||
|
line = buf.getvalue().strip().splitlines()[-1]
|
||||||
|
result = json.loads(line)
|
||||||
|
assert result.get("success") is True
|
||||||
|
assert (result.get("data") or {}).get("status") == "released"
|
||||||
|
|
||||||
|
def test_force_release_inactive_fails(self, clean_db):
|
||||||
|
from service.account_service import cmd_lease_force_release
|
||||||
|
import io, sys
|
||||||
|
|
||||||
|
captured = io.StringIO()
|
||||||
|
sys.stdout = captured
|
||||||
|
try:
|
||||||
|
cmd_lease_force_release(999999)
|
||||||
|
finally:
|
||||||
|
sys.stdout = sys.__stdout__
|
||||||
|
|
||||||
|
result = json.loads(captured.getvalue())
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
class TestLeaseCleanup:
|
class TestLeaseCleanup:
|
||||||
"""lease cleanup / expired lease handling."""
|
"""lease cleanup / expired lease handling."""
|
||||||
|
|||||||
@@ -78,7 +78,22 @@ class TestRowOpenActions(unittest.TestCase):
|
|||||||
self.assertEqual(copy.get("placements"), ["row"])
|
self.assertEqual(copy.get("placements"), ["row"])
|
||||||
self.assertEqual(copy.get("interaction"), "direct")
|
self.assertEqual(copy.get("interaction"), "direct")
|
||||||
self.assertFalse(copy.get("replaces"))
|
self.assertFalse(copy.get("replaces"))
|
||||||
steps = copy.get("steps") or []
|
|
||||||
|
def test_lease_force_release_row_action(self) -> None:
|
||||||
|
action = _by_id(_load_actions(), "lease-force-release")
|
||||||
|
self.assertEqual(action.get("label"), "强制释放租约")
|
||||||
|
self.assertEqual(action.get("placements"), ["row"])
|
||||||
|
self.assertEqual(action.get("interaction"), "confirm")
|
||||||
|
self.assertEqual(action.get("executionProfile"), "sync")
|
||||||
|
bind = action.get("bind") or {}
|
||||||
|
self.assertEqual(bind.get("tables"), ["account_leases"])
|
||||||
|
self.assertEqual(bind.get("inputMapping"), {"leaseId": "$row.$pk"})
|
||||||
|
self.assertEqual(bind.get("visibleWhen"), {"status": "active"})
|
||||||
|
self.assertEqual(
|
||||||
|
(action.get("entrypoint") or {}).get("args"),
|
||||||
|
["force-release", "--id", "{{leaseId}}"],
|
||||||
|
)
|
||||||
|
steps = action.get("steps") or []
|
||||||
self.assertTrue(any(s.get("do") == "reloadTable" for s in steps))
|
self.assertTrue(any(s.get("do") == "reloadTable" for s in steps))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user