From 3ce0cc40a16c04a2c5fb0dbad3e7447148eebef5 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 12 Sep 2026 16:18:36 +0800 Subject: [PATCH] Release v2.0.25 force-release lease row action --- CHANGELOG.md | 13 +++++++ SKILL.md | 4 +- assets/actions.json | 38 +++++++++++++++++++ references/CLI.md | 1 + references/ERRORS.md | 2 +- references/README.md | 2 +- scripts/cli/app.py | 20 ++++++++-- scripts/db/accounts_repo.py | 53 ++++++++++++++++++++++++++ scripts/service/account_service.py | 8 +++- scripts/service/lease_commands.py | 42 +++++++++++++++++++- tests/test_lease.py | 61 ++++++++++++++++++++++++++++++ tests/test_row_open_actions.py | 17 ++++++++- 12 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..76e34bf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +本文件对应技能市场 **「更新日志」** Tab。 +发布 CI 会按当前 tag 版本提取匹配的 `##` 小节。 + +## 2.0.25 + +- 「账号租约」表新增行右键「强制释放租约」:仅状态为 active 时显示,确认后立刻释放,无需租约令牌 +- 同步提供 CLI:`lease force-release --id <租约编号>` + +## 2.0.24 + +- (历史版本,见 git tag) diff --git a/SKILL.md b/SKILL.md index 97a92b2..9491993 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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.24 +version: 2.0.25 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -39,7 +39,7 @@ allowed-tools: 10. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 11. **打开浏览器**:`account open `(或兼容别名 `open `)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。 12. **打开目录**:`account reveal-profile ` — 在系统文件管理器中打开该账号 `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。 15. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 16. **按 lease 取明文密码**:`account get-credential --reveal --lease-token ` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。 diff --git a/assets/actions.json b/assets/actions.json index 09a0ca9..0ba994e 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -248,6 +248,44 @@ "command": "account", "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}}"] + } } ] } diff --git a/references/CLI.md b/references/CLI.md index 707007e..8784043 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -76,6 +76,7 @@ | `credential pick --platform

[--type ] [--environment ] [--role ] [--reveal]` | v1 兼容:挑选凭据 | | `credential resolve --id [--reveal]` | v1 兼容:按凭据 id 解析 | | `lease release ` | 释放租约 | +| `lease force-release --id <租约编号>` | 按行编号强制释放活跃租约(数据管理右键) | | `lease list [--active]` | 列出租约 | | `lease cleanup` | 清理过期租约 | | `account open ` / `open ` | 打开浏览器 | diff --git a/references/ERRORS.md b/references/ERRORS.md index be34eae..219a365 100644 --- a/references/ERRORS.md +++ b/references/ERRORS.md @@ -67,7 +67,7 @@ | 前缀 | 含义 | |---|---| | `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease | -| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 | +| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约(含 force-release 时行不存在或已非 active) | | `ERROR:LEASE_EXPIRED` | 租约已过期 | --- diff --git a/references/README.md b/references/README.md index 5423a10..9897622 100644 --- a/references/README.md +++ b/references/README.md @@ -59,4 +59,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA --- -**作者**:深圳匠厂科技有限公司 · **版本**:2.0.24 +**作者**:深圳匠厂科技有限公司 · **版本**:2.0.25 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 35a8f6d..fb25017 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -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 init-master-key|export-credentials|import-credentials credential pick|resolve - lease release|list|cleanup + lease release|force-release|list|cleanup (legacy) add|list|get|list-json|pick-web|open|delete Machine commands output single-line JSON where appropriate. @@ -42,6 +42,7 @@ from service.account_service import ( cmd_get, cmd_init_db, cmd_lease_cleanup, + cmd_lease_force_release, cmd_lease_list, cmd_lease_release, cmd_list, @@ -121,6 +122,7 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager 租约管理: lease release + lease force-release --id <租约编号> lease list [--active] lease cleanup @@ -369,7 +371,7 @@ def main() -> None: # ---- lease subcommand ---- elif cmd == "lease": 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) sub = av[2] if sub == "release": @@ -377,13 +379,25 @@ def main() -> None: print("ERROR:CLI_BAD_ARGS lease release 需要 。") sys.exit(1) 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": active = _has_flag(av, "--active") cmd_lease_list(active) elif sub == "cleanup": cmd_lease_cleanup() else: - print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。") + print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、force-release、list 或 cleanup。") sys.exit(1) # ---- Legacy aliases ---- diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index 8092e59..3404e98 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -795,6 +795,59 @@ def release_lease(lease_token: str) -> bool: 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]: """List all active (non-expired, non-released) leases.""" conn = get_conn() diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index fd19487..18cf4a1 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -39,7 +39,12 @@ from service.legacy_commands import ( cmd_delete_by_platform_phone, 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 ( cmd_init_db, cmd_platform_ensure, @@ -70,6 +75,7 @@ __all__ = [ "cmd_get", "cmd_init_db", "cmd_lease_cleanup", + "cmd_lease_force_release", "cmd_lease_list", "cmd_lease_release", "cmd_list", diff --git a/scripts/service/lease_commands.py b/scripts/service/lease_commands.py index f417a7f..63da470 100644 --- a/scripts/service/lease_commands.py +++ b/scripts/service/lease_commands.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- """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 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", "未找到活跃的租约,或租约已释放/过期。")) +def cmd_lease_force_release(lease_id: int) -> None: + """lease force-release --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: """lease list [--active]""" log = get_skill_logger() diff --git a/tests/test_lease.py b/tests/test_lease.py index 1beeb7f..b2cfe31 100644 --- a/tests/test_lease.py +++ b/tests/test_lease.py @@ -144,6 +144,67 @@ class TestLeaseRelease: assert result["success"] is False 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: """lease cleanup / expired lease handling.""" diff --git a/tests/test_row_open_actions.py b/tests/test_row_open_actions.py index fb13c69..4562ccf 100644 --- a/tests/test_row_open_actions.py +++ b/tests/test_row_open_actions.py @@ -78,7 +78,22 @@ class TestRowOpenActions(unittest.TestCase): self.assertEqual(copy.get("placements"), ["row"]) self.assertEqual(copy.get("interaction"), "direct") 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))