Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44eafff757 | |||
| 3ce0cc40a1 | |||
| 3f1cde1f99 |
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)
|
||||||
35
SKILL.md
35
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.23
|
version: 2.0.26
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
@@ -28,22 +28,23 @@ allowed-tools:
|
|||||||
## 何时使用本技能
|
## 何时使用本技能
|
||||||
|
|
||||||
1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行)
|
1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行)
|
||||||
2. **登记网页/RPA 账号**:`account add-web` — 平台 + 账号名称 + 可选登录账号即可;环境/角色/登录策略跟平台默认。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。
|
2. **登记平台**:`platform ensure --key <小写键> --display-name <展示名>`。键与展示名必须分开;`add-web` / `ensure-web` / `pick-web` 只引用已注册平台,**不会**根据 `--platform-display-name` 自动建平台。数据管理「平台信息 → 新增」走本命令。
|
||||||
3. **复制账号**:`account copy <id>` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。
|
3. **登记网页/RPA 账号**:`account add-web` — 平台 + 账号名称 + 可选登录账号即可;环境/角色/登录策略跟平台默认。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。
|
||||||
4. **编辑账号**:`account update --id <id>` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。
|
4. **复制账号**:`account copy <id>` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。
|
||||||
5. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
|
5. **编辑账号**:`account update --id <id>` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。
|
||||||
6. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
|
6. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
|
||||||
7. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
|
7. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
|
||||||
8. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
|
8. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
|
||||||
9. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
9. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
|
||||||
10. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
|
10. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||||
11. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
|
11. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
|
||||||
12. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
12. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
|
||||||
13. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
13. **管理 lease**:`lease release/force-release/list/cleanup` — 防止 RPA 并发;数据管理「账号租约」行右键可强制释放。
|
||||||
14. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
14. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
||||||
15. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
15. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
||||||
16. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
16. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
||||||
17. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
|
17. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||||
|
18. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
|
||||||
|
|
||||||
## CLI 调用
|
## CLI 调用
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,49 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"skill": "account-manager",
|
"skill": "account-manager",
|
||||||
"actions": [
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "platform-ensure",
|
||||||
|
"label": "新增平台",
|
||||||
|
"description": "登记平台:小写英文键 + 展示名必须分开。不会把中文名当成键。",
|
||||||
|
"replaces": "create",
|
||||||
|
"placements": [],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "form",
|
||||||
|
"bind": {
|
||||||
|
"tables": ["platforms"]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["platform_key", "display_name"],
|
||||||
|
"properties": {
|
||||||
|
"platform_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "平台键",
|
||||||
|
"description": "小写英文/数字/下划线或连字符,例如 douyin、deepseek。不要填中文。"
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "展示名称",
|
||||||
|
"description": "给人看的名称,可以是中文,例如 抖音。"
|
||||||
|
},
|
||||||
|
"default_url": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "默认链接"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "platform",
|
||||||
|
"args": [
|
||||||
|
"ensure",
|
||||||
|
"--key", "{{platform_key}}",
|
||||||
|
"--display-name", "{{display_name}}",
|
||||||
|
"--url", "{{default_url}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "account-add-web",
|
"id": "account-add-web",
|
||||||
"label": "新增账号",
|
"label": "新增账号",
|
||||||
@@ -205,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}}"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) |
|
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) |
|
||||||
| `platform get <platform>` | 获取数据库中的平台详情 |
|
| `platform get <platform>` | 获取数据库中的平台详情 |
|
||||||
|
| `platform ensure --key <key> --display-name <name> [--url …]` | **登记/更新平台**。`--key` 必须已是小写英文键(如 `douyin`);`--display-name` **必填**(可中文)。不会从 `add-web` / `pick-web` 自动建平台。 |
|
||||||
|
|
||||||
### B. 加密初始化(v2)
|
### B. 加密初始化(v2)
|
||||||
|
|
||||||
@@ -75,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>` | 打开浏览器 |
|
||||||
@@ -223,7 +225,7 @@ ACCOUNT_MANAGER_MASTER_KEY="<base64-44-chars>" python main.py account init-maste
|
|||||||
|
|
||||||
## 通用说明
|
## 通用说明
|
||||||
|
|
||||||
- **平台参数**:可为 `scripts/util/platforms.py` 中的英文键、`display_name`、任一 alias(不区分大小写)。详见 [PLATFORMS.md](PLATFORMS.md)。
|
- **平台参数**:已注册的英文键、或能唯一对应到一个键的 `display_name`/alias(不区分大小写)。冲突别名(如「公众号」同时对应 `wechat` / `wechat_mp`)会返回 `AMBIGUOUS_PLATFORM`,请改传明确键。未注册的键**不会**自动插入 `platforms` 表。详见 [PLATFORMS.md](PLATFORMS.md)。
|
||||||
- **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
|
- **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。
|
||||||
- **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
|
- **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。
|
||||||
- **错误码**:见 [ERRORS.md](ERRORS.md)。
|
- **错误码**:见 [ERRORS.md](ERRORS.md)。
|
||||||
|
|||||||
@@ -46,7 +46,13 @@
|
|||||||
|
|
||||||
| 前缀 | 含义 |
|
| 前缀 | 含义 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
|
| `ERROR:INVALID_PLATFORM` | 平台名为空,或中文/人话名称无法对应到已注册平台 |
|
||||||
|
| `ERROR:INVALID_PLATFORM_KEY` | 键格式非法(大写、空格、中文当键等);展示名不能当键 |
|
||||||
|
| `ERROR:PLATFORM_NOT_REGISTERED` | 键格式合法但尚未 `platform ensure`;**已拒绝写入**,不会自动建平台 |
|
||||||
|
| `ERROR:AMBIGUOUS_PLATFORM` | 展示名/别名对应多个键(如「公众号」→ `wechat` 与 `wechat_mp`),请改传明确键 |
|
||||||
|
| `ERROR:PLATFORM_ALIAS_CONFLICT` | `platform ensure` 的展示名/别名已被其它平台占用 |
|
||||||
|
| `ERROR:INVALID_PLATFORM_DISPLAY_NAME` | `platform ensure` 未提供 `--display-name` |
|
||||||
|
| `ERROR:PLATFORM_DISABLED` | 平台已禁用 |
|
||||||
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
|
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
|
||||||
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
|
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
|
||||||
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
|
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
|
||||||
@@ -61,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` | 租约已过期 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。
|
业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。
|
||||||
|
|
||||||
|
新平台必须先 `platform ensure --key <小写键> --display-name <展示名>` 再引用。`add-web` / `ensure-web` / `pick-web` **不会**根据 `--platform-display-name` 自动插入 `platforms` 行。
|
||||||
|
|
||||||
## 两种集成方式
|
## 两种集成方式
|
||||||
|
|
||||||
| 方式 | 适用场景 | 调用形态 |
|
| 方式 | 适用场景 | 调用形态 |
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
| 目的 | 命令 | 成功时 stdout(摘要) |
|
| 目的 | 命令 | 成功时 stdout(摘要) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 枚举平台 | `platform list [--domain ...]` | `{"success": true, "data": {"platforms": [...]}}` |
|
| 枚举平台 | `platform list [--domain ...]` | `{"success": true, "data": {"platforms": [...]}}` |
|
||||||
|
| 登记平台 | `platform ensure --key <小写键> --display-name <展示名>` | `{"ok": true, "platform_key": "...", "action": "created\|updated"}` |
|
||||||
| 平台详情 | `platform get <p>` | `{"success": true, "data": {...}}` |
|
| 平台详情 | `platform get <p>` | `{"success": true, "data": {...}}` |
|
||||||
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
|
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
|
||||||
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
|
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
|
**权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。
|
||||||
|
|
||||||
解析函数:`resolve_platform_key(name)` — 支持英文键、`display_name`、任一 alias(大小写不敏感)。
|
解析:
|
||||||
|
|
||||||
|
- **引用**(`add-web` / `ensure-web` / `pick-web` / `platform get`):`resolve_platform_key_dynamic` — 已有键精确匹配;已有键可大小写折叠(`DeepSeek` → `deepseek`);展示名/别名仅在**唯一**对应一个键时通过。未注册键**拒绝写入**,不会自动 `INSERT`。
|
||||||
|
- **登记**(仅 `platform ensure`):键必须符合 `^[a-z0-9][a-z0-9_-]{1,63}$` 且**本身已是小写**(`HaoKan` 非法);`--display-name` 必填;别名不得抢其它平台已占用的展示名/别名。
|
||||||
|
|
||||||
|
`resolve_platform_key(name)` 仍是内置表的静态别名映射(无 DB、冲突别名后写覆盖),新代码请走动态解析。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,3 +104,5 @@ CLI `platform list --domain` **仅接受** `logistics | llm | content`(见 `cm
|
|||||||
## 平台注册到数据库
|
## 平台注册到数据库
|
||||||
|
|
||||||
`seed_platforms(conn)` 在业务流程中随 `init_db()` 触发,将 `PLATFORMS` upsert 进 `platforms` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。
|
`seed_platforms(conn)` 在业务流程中随 `init_db()` 触发,将 `PLATFORMS` upsert 进 `platforms` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。
|
||||||
|
|
||||||
|
业务 skill 要加新平台:先 `platform ensure --key <小写键> --display-name <展示名> [--url …]`,再 `add-web` / `ensure-web`。不要把中文名或 `HaoKan` 这种大小写混写当成键;也不要指望 `--platform-display-name` 自动建行。
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA
|
|||||||
|
|
||||||
## 它能帮你做什么
|
## 它能帮你做什么
|
||||||
|
|
||||||
|
- **登记自定义平台** — `platform ensure --key douyin --display-name 抖音`;键必须是小写英文,展示名可以是中文。其它技能不能靠 add-web 自动建平台。
|
||||||
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
|
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。
|
||||||
- **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`(Fernet 密文入库)。
|
- **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`(Fernet 密文入库)。
|
||||||
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。
|
- **查看/列出账号** — 按平台、环境、角色等多维度筛选。
|
||||||
@@ -58,4 +59,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.23
|
**作者**:深圳匠厂科技有限公司 · **版本**: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
|
||||||
|
|
||||||
@@ -261,7 +263,7 @@ def main() -> None:
|
|||||||
args = p.parse_args(av[3:])
|
args = p.parse_args(av[3:])
|
||||||
sys.exit(cmd_platform_ensure(
|
sys.exit(cmd_platform_ensure(
|
||||||
key=args.key,
|
key=args.key,
|
||||||
display_name=args.display_name or args.key,
|
display_name=args.display_name,
|
||||||
domain=args.domain,
|
domain=args.domain,
|
||||||
url=args.url,
|
url=args.url,
|
||||||
auth_strategy=args.auth_strategy,
|
auth_strategy=args.auth_strategy,
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ from typing import Optional
|
|||||||
from db.accounts_repo import (
|
from db.accounts_repo import (
|
||||||
insert_account,
|
insert_account,
|
||||||
resolve_auth_strategy_for_platform,
|
resolve_auth_strategy_for_platform,
|
||||||
ensure_platform_from_caller_meta,
|
|
||||||
)
|
)
|
||||||
from db.auth_strategy import (
|
from db.auth_strategy import (
|
||||||
ALL_AUTH_STRATEGIES,
|
|
||||||
AUTH_STRATEGY_CLIENT_CERTIFICATE,
|
AUTH_STRATEGY_CLIENT_CERTIFICATE,
|
||||||
AUTH_STRATEGY_DEVICE_BOUND,
|
AUTH_STRATEGY_DEVICE_BOUND,
|
||||||
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
AUTH_STRATEGY_PASSWORD_PLUS_2FA,
|
||||||
@@ -22,14 +20,10 @@ from db.auth_strategy import (
|
|||||||
AUTH_STRATEGY_SSO_REDIRECT,
|
AUTH_STRATEGY_SSO_REDIRECT,
|
||||||
)
|
)
|
||||||
from db.connection import get_conn, init_db
|
from db.connection import get_conn, init_db
|
||||||
from util.logging_config import get_skill_logger
|
|
||||||
from util.platforms import (
|
from util.platforms import (
|
||||||
DEFAULT_CAPABILITIES_JSON,
|
|
||||||
PlatformResolveResult,
|
PlatformResolveResult,
|
||||||
_platform_list_cn_for_help,
|
_platform_list_cn_for_help,
|
||||||
is_valid_platform_key_format,
|
|
||||||
resolve_platform_key_dynamic,
|
resolve_platform_key_dynamic,
|
||||||
seed_platforms,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -137,7 +131,7 @@ def _remove_profile_dir(path: str) -> None:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlatformCallerMeta:
|
class PlatformCallerMeta:
|
||||||
"""调用方为未注册自定义平台提供的自动注册元数据(pick-web / add-web)。"""
|
"""调用方可附带的平台展示信息。不再用于自动插入 platforms 行。"""
|
||||||
|
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
@@ -145,8 +139,17 @@ class PlatformCallerMeta:
|
|||||||
auth_strategy: Optional[str] = None
|
auth_strategy: Optional[str] = None
|
||||||
aliases: list[str] = field(default_factory=list)
|
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())
|
_RESOLVE_CODES_WITHOUT_PLATFORM_LIST = frozenset(
|
||||||
|
{
|
||||||
|
"PLATFORM_NOT_REGISTERED",
|
||||||
|
"INVALID_PLATFORM_KEY",
|
||||||
|
"PLATFORM_DISABLED",
|
||||||
|
"AMBIGUOUS_PLATFORM",
|
||||||
|
"PLATFORM_ALIAS_CONFLICT",
|
||||||
|
"INVALID_PLATFORM_DISPLAY_NAME",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _emit_platform_resolve_error(result: PlatformResolveResult, hint: str = "") -> None:
|
def _emit_platform_resolve_error(result: PlatformResolveResult, hint: str = "") -> None:
|
||||||
@@ -164,77 +167,9 @@ def _resolve_or_auto_register(
|
|||||||
*,
|
*,
|
||||||
hint: str = "",
|
hint: str = "",
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""解析平台;未注册时可在元数据充足时自动 ensure(幂等、仅填空字段)。"""
|
"""解析已注册平台;未注册则拒绝。meta 仅兼容旧调用方,不再自动建平台。"""
|
||||||
init_db()
|
del meta
|
||||||
conn = get_conn()
|
return _resolve_or_fail(platform_input, hint=hint)
|
||||||
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]:
|
def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]:
|
||||||
@@ -250,6 +185,6 @@ def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]:
|
|||||||
return result.platform_key
|
return result.platform_key
|
||||||
|
|
||||||
_emit_platform_resolve_error(result, hint=hint)
|
_emit_platform_resolve_error(result, hint=hint)
|
||||||
if result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"):
|
if result.error_code not in _RESOLVE_CODES_WITHOUT_PLATFORM_LIST:
|
||||||
_say("支持:" + _platform_list_cn_for_help())
|
_say("支持:" + _platform_list_cn_for_help())
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from db.connection import get_conn, init_db
|
|||||||
from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say
|
from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say
|
||||||
from util.constants import SKILL_SLUG
|
from util.constants import SKILL_SLUG
|
||||||
from util.logging_config import get_skill_logger
|
from util.logging_config import get_skill_logger
|
||||||
from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms
|
from util.platforms import DEFAULT_CAPABILITIES_JSON, seed_platforms
|
||||||
from util.runtime_paths import get_db_path
|
from util.runtime_paths import get_db_path
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +76,20 @@ def cmd_platform_get(input_key: str) -> None:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _aliases_for_ensure(aliases: Optional[str], key: str, display_name: str) -> tuple[list[str], str]:
|
||||||
|
from util.platforms import _parse_aliases_json
|
||||||
|
|
||||||
|
claimed: list[str] = []
|
||||||
|
extra = _parse_aliases_json(aliases) if aliases else []
|
||||||
|
for item in (display_name, key, *extra):
|
||||||
|
text = (item or "").strip()
|
||||||
|
if text and text not in claimed:
|
||||||
|
claimed.append(text)
|
||||||
|
if aliases:
|
||||||
|
return claimed, aliases
|
||||||
|
return claimed, json.dumps(claimed, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def cmd_platform_ensure(
|
def cmd_platform_ensure(
|
||||||
key: str,
|
key: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
@@ -89,25 +103,33 @@ def cmd_platform_ensure(
|
|||||||
幂等地注册或更新平台到 DB。
|
幂等地注册或更新平台到 DB。
|
||||||
输出单行 JSON:{"ok": true, "platform_key": "xxx", "action": "created|updated"}
|
输出单行 JSON:{"ok": true, "platform_key": "xxx", "action": "created|updated"}
|
||||||
"""
|
"""
|
||||||
|
from util.platform_validate import validate_platform_registration
|
||||||
|
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
key = (key or "").strip()
|
key = (key or "").strip()
|
||||||
display_name = (display_name or key).strip()
|
display_name = (display_name or "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
_say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数')
|
_say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数')
|
||||||
return 1
|
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()
|
init_db()
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
|
alias_list, aliases_json = _aliases_for_ensure(aliases, key, display_name)
|
||||||
|
check = validate_platform_registration(
|
||||||
|
key,
|
||||||
|
display_name,
|
||||||
|
aliases=alias_list,
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
if not check.ok:
|
||||||
|
_say(_err_json(
|
||||||
|
f"ERROR:{check.error_code}",
|
||||||
|
check.message or "平台登记校验失败。",
|
||||||
|
))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON
|
||||||
existed = bool(
|
existed = bool(
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"SELECT 1 FROM platforms WHERE platform_key = ?", (key,)
|
"SELECT 1 FROM platforms WHERE platform_key = ?", (key,)
|
||||||
|
|||||||
264
scripts/util/platform_validate.py
Normal file
264
scripts/util/platform_validate.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""平台键 / 展示名校验:引用已有平台 vs 登记新平台。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
from util.platforms import (
|
||||||
|
PLATFORMS,
|
||||||
|
PlatformResolveResult,
|
||||||
|
_parse_aliases_json,
|
||||||
|
is_valid_platform_key_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ASCII_TOKEN = re.compile(r"[A-Za-z0-9]")
|
||||||
|
|
||||||
|
|
||||||
|
def msg_empty() -> str:
|
||||||
|
return "平台名不能为空。请传平台键(如 douyin、deepseek),不要传空字符串。"
|
||||||
|
|
||||||
|
|
||||||
|
def msg_invalid_key(raw: str) -> str:
|
||||||
|
return (
|
||||||
|
f"平台键不合法:「{raw}」。\n"
|
||||||
|
"平台键必须是小写英文/数字/下划线或连字符,例如 douyin、deepseek。\n"
|
||||||
|
"「抖音」「DeepSeek」是展示名,不能当键。请改成已有键,或先登记:\n"
|
||||||
|
"platform ensure --key <键> --display-name <中文名>\n"
|
||||||
|
"查看已有键:platform list"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def msg_unrecognized(raw: str) -> str:
|
||||||
|
return (
|
||||||
|
f"无法识别平台:「{raw}」。\n"
|
||||||
|
"请传平台键(如 douyin),不要自创中文键。\n"
|
||||||
|
"先执行 platform list 核对;若是新平台,用 "
|
||||||
|
"platform ensure --key <键> --display-name <中文名> 登记后再调 add-web / ensure-web。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def msg_not_registered(key: str) -> str:
|
||||||
|
return (
|
||||||
|
f"平台 `{key}` 尚未注册,已拒绝写入,以免产生不规范平台。\n"
|
||||||
|
f"若确认是新平台:platform ensure --key {key} --display-name <中文名> [--url …]\n"
|
||||||
|
"若其实是已有站:请改用 platform list 里的平台键。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def msg_ambiguous(raw: str, keys: Iterable[str]) -> str:
|
||||||
|
listed = "、".join(f"`{k}`" for k in keys)
|
||||||
|
return f"平台「{raw}」对应多个键({listed}),请改传明确的平台键。"
|
||||||
|
|
||||||
|
|
||||||
|
def msg_alias_conflict(alias: str, owner: str, key: str) -> str:
|
||||||
|
return (
|
||||||
|
f"别名「{alias}」已被平台 `{owner}` 占用,不能再登记到 `{key}`。"
|
||||||
|
"请换展示名/别名,或复用已有平台键。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def msg_display_name_required() -> str:
|
||||||
|
return (
|
||||||
|
"登记平台必须同时提供 --display-name(给人看的名称,可以是中文)。\n"
|
||||||
|
"平台键与展示名要分开,例如 --key douyin --display-name 抖音。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def msg_disabled(key: str) -> str:
|
||||||
|
return f"平台 {key} 已禁用。"
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_attempted_key(raw: str) -> bool:
|
||||||
|
return bool(_ASCII_TOKEN.search(raw or "")) and not is_valid_platform_key_format(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_stored_key(conn, key_lower: str) -> Optional[tuple[str, int]]:
|
||||||
|
"""Return (platform_key, enabled) for an exact key, builtin or DB."""
|
||||||
|
needle = (key_lower or "").strip().lower()
|
||||||
|
if not needle:
|
||||||
|
return None
|
||||||
|
if conn is not None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT platform_key, enabled FROM platforms WHERE lower(platform_key) = ?",
|
||||||
|
(needle,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return str(row[0]), int(row[1] or 0)
|
||||||
|
if needle in PLATFORMS:
|
||||||
|
return needle, 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_enabled_name_map(conn) -> list[tuple[str, set[str]]]:
|
||||||
|
"""Each enabled platform with its display_name + aliases (not the key)."""
|
||||||
|
out: list[tuple[str, set[str]]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
if conn is not None:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT platform_key, enabled, display_name, aliases_json FROM platforms"
|
||||||
|
).fetchall()
|
||||||
|
for platform_key, enabled, display_name, aliases_json in rows:
|
||||||
|
key = str(platform_key)
|
||||||
|
seen.add(key)
|
||||||
|
if not enabled:
|
||||||
|
continue
|
||||||
|
names: set[str] = set()
|
||||||
|
if display_name:
|
||||||
|
names.add(str(display_name).strip())
|
||||||
|
for alias in _parse_aliases_json(aliases_json):
|
||||||
|
names.add(alias)
|
||||||
|
out.append((key, {n for n in names if n}))
|
||||||
|
for key, spec in PLATFORMS.items():
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
names = {str(spec.get("display_name") or "").strip()}
|
||||||
|
for alias in spec.get("aliases") or []:
|
||||||
|
names.add(str(alias).strip())
|
||||||
|
out.append((key, {n for n in names if n}))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _alias_matches(raw: str, conn) -> list[str]:
|
||||||
|
needle = (raw or "").strip()
|
||||||
|
if not needle:
|
||||||
|
return []
|
||||||
|
lower = needle.lower()
|
||||||
|
matches: list[str] = []
|
||||||
|
for key, names in _iter_enabled_name_map(conn):
|
||||||
|
lowered = {n.lower() for n in names}
|
||||||
|
if needle in names or lower in lowered:
|
||||||
|
if key not in matches:
|
||||||
|
matches.append(key)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _keys_claiming_label(label: str, conn) -> list[str]:
|
||||||
|
"""Keys that already use this string as key, display_name, or alias."""
|
||||||
|
text = (label or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
owners: list[str] = []
|
||||||
|
stored = _lookup_stored_key(conn, text.lower())
|
||||||
|
if stored and stored[0] not in owners:
|
||||||
|
owners.append(stored[0])
|
||||||
|
for key in _alias_matches(text, conn):
|
||||||
|
if key not in owners:
|
||||||
|
owners.append(key)
|
||||||
|
return owners
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_platform_ref(input_name: str, conn=None) -> PlatformResolveResult:
|
||||||
|
"""引用已有平台:已有键优先,其次唯一展示名/别名。不创建平台行。"""
|
||||||
|
raw = (input_name or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return PlatformResolveResult(error_code="INVALID_PLATFORM", message=msg_empty())
|
||||||
|
|
||||||
|
close_conn = False
|
||||||
|
if conn is None:
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
close_conn = True
|
||||||
|
try:
|
||||||
|
if is_valid_platform_key_format(raw):
|
||||||
|
stored = _lookup_stored_key(conn, raw)
|
||||||
|
if stored:
|
||||||
|
key, enabled = stored
|
||||||
|
if not enabled:
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="PLATFORM_DISABLED",
|
||||||
|
message=msg_disabled(key),
|
||||||
|
)
|
||||||
|
return PlatformResolveResult(platform_key=key)
|
||||||
|
|
||||||
|
folded = raw.lower()
|
||||||
|
if is_valid_platform_key_format(folded):
|
||||||
|
stored = _lookup_stored_key(conn, folded)
|
||||||
|
if stored:
|
||||||
|
key, enabled = stored
|
||||||
|
if not enabled:
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="PLATFORM_DISABLED",
|
||||||
|
message=msg_disabled(key),
|
||||||
|
)
|
||||||
|
return PlatformResolveResult(platform_key=key)
|
||||||
|
|
||||||
|
matches = _alias_matches(raw, conn)
|
||||||
|
if len(matches) == 1:
|
||||||
|
return PlatformResolveResult(platform_key=matches[0])
|
||||||
|
if len(matches) > 1:
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="AMBIGUOUS_PLATFORM",
|
||||||
|
message=msg_ambiguous(raw, matches),
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_valid_platform_key_format(raw):
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="PLATFORM_NOT_REGISTERED",
|
||||||
|
message=msg_not_registered(raw),
|
||||||
|
)
|
||||||
|
if _looks_like_attempted_key(raw) or is_valid_platform_key_format(folded):
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="INVALID_PLATFORM_KEY",
|
||||||
|
message=msg_invalid_key(raw),
|
||||||
|
)
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="INVALID_PLATFORM",
|
||||||
|
message=msg_unrecognized(raw),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if close_conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_platform_registration(
|
||||||
|
key: str,
|
||||||
|
display_name: str,
|
||||||
|
aliases: Optional[Iterable[Any]] = None,
|
||||||
|
conn=None,
|
||||||
|
) -> PlatformResolveResult:
|
||||||
|
"""登记/更新平台:键必须已是规范小写;展示名必填;别名不得抢别人的。"""
|
||||||
|
raw_key = (key or "").strip()
|
||||||
|
if not raw_key:
|
||||||
|
return PlatformResolveResult(error_code="INVALID_PLATFORM", message=msg_empty())
|
||||||
|
if not is_valid_platform_key_format(raw_key):
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="INVALID_PLATFORM_KEY",
|
||||||
|
message=msg_invalid_key(raw_key),
|
||||||
|
)
|
||||||
|
name = (display_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="INVALID_PLATFORM_DISPLAY_NAME",
|
||||||
|
message=msg_display_name_required(),
|
||||||
|
)
|
||||||
|
|
||||||
|
close_conn = False
|
||||||
|
if conn is None:
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
close_conn = True
|
||||||
|
try:
|
||||||
|
claimed = [name]
|
||||||
|
if aliases:
|
||||||
|
for item in aliases:
|
||||||
|
text = str(item).strip()
|
||||||
|
if text:
|
||||||
|
claimed.append(text)
|
||||||
|
for label in claimed:
|
||||||
|
if label.lower() == raw_key:
|
||||||
|
continue
|
||||||
|
owners = [k for k in _keys_claiming_label(label, conn) if k != raw_key]
|
||||||
|
if owners:
|
||||||
|
return PlatformResolveResult(
|
||||||
|
error_code="PLATFORM_ALIAS_CONFLICT",
|
||||||
|
message=msg_alias_conflict(label, owners[0], raw_key),
|
||||||
|
)
|
||||||
|
return PlatformResolveResult(platform_key=raw_key)
|
||||||
|
finally:
|
||||||
|
if close_conn:
|
||||||
|
conn.close()
|
||||||
@@ -337,8 +337,8 @@ def resolve_platform_key(name: str) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def is_valid_platform_key_format(name: str) -> bool:
|
def is_valid_platform_key_format(name: str) -> bool:
|
||||||
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$"""
|
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$(不大小写折叠;登记必须本身已是小写)。"""
|
||||||
s = (name or "").strip().lower()
|
s = (name or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return False
|
return False
|
||||||
return bool(PLATFORM_KEY_PATTERN.match(s))
|
return bool(PLATFORM_KEY_PATTERN.match(s))
|
||||||
@@ -367,87 +367,11 @@ class PlatformResolveResult:
|
|||||||
return bool(self.platform_key) and not self.error_code
|
return bool(self.platform_key) and not self.error_code
|
||||||
|
|
||||||
|
|
||||||
def _resolve_platform_from_db(conn, raw: str) -> PlatformResolveResult:
|
|
||||||
"""在 platforms 表中解析:精确 key(enabled=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:
|
def resolve_platform_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult:
|
||||||
"""
|
"""引用已有平台(键 / 唯一展示名或别名)。实现见 util.platform_validate。"""
|
||||||
统一平台解析:内置 PLATFORMS → DB platforms 表 → 错误分类。
|
from util.platform_validate import resolve_platform_ref
|
||||||
"""
|
|
||||||
raw = (input_name or "").strip()
|
|
||||||
if not raw:
|
|
||||||
return PlatformResolveResult(
|
|
||||||
error_code="INVALID_PLATFORM",
|
|
||||||
message="平台名不能为空。",
|
|
||||||
)
|
|
||||||
|
|
||||||
builtin = resolve_platform_key(raw)
|
return resolve_platform_ref(input_name, conn)
|
||||||
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]:
|
def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TestDynamicPlatformResolution:
|
|||||||
|
|
||||||
result = resolve_platform_key_dynamic("江厂 空格", conn=clean_db)
|
result = resolve_platform_key_dynamic("江厂 空格", conn=clean_db)
|
||||||
assert not result.ok
|
assert not result.ok
|
||||||
assert result.error_code == "INVALID_PLATFORM_KEY"
|
assert result.error_code == "INVALID_PLATFORM"
|
||||||
|
|
||||||
def test_empty_platform(self, clean_db):
|
def test_empty_platform(self, clean_db):
|
||||||
from util.platforms import resolve_platform_key_dynamic
|
from util.platforms import resolve_platform_key_dynamic
|
||||||
@@ -97,6 +97,43 @@ class TestPlatformEnsure:
|
|||||||
assert payload["platform_key"] == "jiangchang"
|
assert payload["platform_key"] == "jiangchang"
|
||||||
assert payload["action"] in ("created", "updated")
|
assert payload["action"] in ("created", "updated")
|
||||||
|
|
||||||
|
def test_ensure_requires_display_name(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_platform_ensure
|
||||||
|
|
||||||
|
rc = cmd_platform_ensure(key="hao_kan", display_name="")
|
||||||
|
assert rc == 1
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip())
|
||||||
|
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_DISPLAY_NAME"
|
||||||
|
count = clean_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
def test_ensure_rejects_mixed_case_key(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_platform_ensure
|
||||||
|
|
||||||
|
rc = cmd_platform_ensure(key="HaoKan", display_name="好看")
|
||||||
|
assert rc == 1
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip())
|
||||||
|
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY"
|
||||||
|
count = clean_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM platforms WHERE lower(platform_key) = 'haokan'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
def test_ensure_rejects_alias_owned_by_another_platform(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_platform_ensure
|
||||||
|
|
||||||
|
rc = cmd_platform_ensure(key="hao_kan", display_name="抖音")
|
||||||
|
assert rc == 1
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip())
|
||||||
|
assert payload["error"]["code"] == "ERROR:PLATFORM_ALIAS_CONFLICT"
|
||||||
|
assert "douyin" in payload["error"]["message"]
|
||||||
|
count = clean_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
class TestCustomPlatformAccounts:
|
class TestCustomPlatformAccounts:
|
||||||
def test_add_web_after_ensure(self, clean_db):
|
def test_add_web_after_ensure(self, clean_db):
|
||||||
@@ -161,7 +198,8 @@ class TestCustomPlatformAccounts:
|
|||||||
payload = json.loads(out)
|
payload = json.loads(out)
|
||||||
assert payload["success"] is False
|
assert payload["success"] is False
|
||||||
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
|
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
|
||||||
assert "--platform-display-name" in payload["error"]["message"]
|
assert "platform ensure" in payload["error"]["message"]
|
||||||
|
assert "--platform-display-name" not in payload["error"]["message"]
|
||||||
|
|
||||||
def test_add_web_unregistered_returns_structured_error(self, clean_db, capsys):
|
def test_add_web_unregistered_returns_structured_error(self, clean_db, capsys):
|
||||||
from service.account_service import cmd_account_add_web
|
from service.account_service import cmd_account_add_web
|
||||||
@@ -255,7 +293,7 @@ JIANGCHANG_META_KWARGS = dict(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAutoRegisterFromCallerMeta:
|
class TestCallerMetaDoesNotAutoRegister:
|
||||||
def _meta(self):
|
def _meta(self):
|
||||||
from service.account_service import PlatformCallerMeta
|
from service.account_service import PlatformCallerMeta
|
||||||
|
|
||||||
@@ -293,9 +331,10 @@ class TestAutoRegisterFromCallerMeta:
|
|||||||
payload = json.loads(out)
|
payload = json.loads(out)
|
||||||
assert payload["success"] is False
|
assert payload["success"] is False
|
||||||
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
|
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
|
||||||
assert "--platform-display-name" in payload["error"]["message"]
|
assert "platform ensure" in payload["error"]["message"]
|
||||||
|
assert "--platform-display-name" not in payload["error"]["message"]
|
||||||
|
|
||||||
def test_pick_web_unregistered_with_meta_auto_registers(self, clean_db, capsys):
|
def test_pick_web_unregistered_with_meta_does_not_insert(self, clean_db, capsys):
|
||||||
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
|
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
|
||||||
from util.platforms import resolve_platform_key_dynamic
|
from util.platforms import resolve_platform_key_dynamic
|
||||||
|
|
||||||
@@ -310,28 +349,33 @@ class TestAutoRegisterFromCallerMeta:
|
|||||||
out = capsys.readouterr().out.strip()
|
out = capsys.readouterr().out.strip()
|
||||||
payload = json.loads(out)
|
payload = json.loads(out)
|
||||||
assert payload["success"] is False
|
assert payload["success"] is False
|
||||||
assert payload["error"]["code"] == "ERROR:NO_ACCOUNT"
|
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
|
||||||
|
assert "已拒绝写入" in payload["error"]["message"]
|
||||||
|
|
||||||
result = resolve_platform_key_dynamic("auto_reg_plat", conn=clean_db)
|
result = resolve_platform_key_dynamic("auto_reg_plat", conn=clean_db)
|
||||||
assert result.ok
|
assert not result.ok
|
||||||
assert result.platform_key == "auto_reg_plat"
|
assert result.error_code == "PLATFORM_NOT_REGISTERED"
|
||||||
|
count = clean_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'auto_reg_plat'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
def test_add_web_unregistered_with_meta_auto_registers(self, clean_db):
|
def test_add_web_unregistered_with_meta_does_not_insert(self, clean_db, capsys):
|
||||||
from service.account_service import cmd_account_add_web, PlatformCallerMeta
|
from service.account_service import cmd_account_add_web, PlatformCallerMeta
|
||||||
from db.accounts_repo import find_accounts
|
from db.accounts_repo import find_accounts
|
||||||
|
|
||||||
meta = PlatformCallerMeta(
|
meta = PlatformCallerMeta(
|
||||||
display_name="匠厂后台",
|
display_name="未登记测试",
|
||||||
url=JIANGCHANG_URL,
|
url=JIANGCHANG_URL,
|
||||||
domain="jc2009.com",
|
domain="example.com",
|
||||||
auth_strategy="qr_code_manual",
|
auth_strategy="qr_code_manual",
|
||||||
aliases=["jiangchang"],
|
aliases=["unreg_meta_plat"],
|
||||||
)
|
)
|
||||||
cmd_account_add_web(
|
cmd_account_add_web(
|
||||||
platform_input="jiangchang",
|
platform_input="unreg_meta_plat",
|
||||||
login_id="manual",
|
login_id="manual",
|
||||||
login_id_type="manual",
|
login_id_type="manual",
|
||||||
label="匠厂后台",
|
label="未登记",
|
||||||
tenant_id=None,
|
tenant_id=None,
|
||||||
environment="production",
|
environment="production",
|
||||||
role="admin",
|
role="admin",
|
||||||
@@ -341,9 +385,15 @@ class TestAutoRegisterFromCallerMeta:
|
|||||||
profile_dir_override=None,
|
profile_dir_override=None,
|
||||||
platform_meta=meta,
|
platform_meta=meta,
|
||||||
)
|
)
|
||||||
accs = find_accounts(platform_key="jiangchang")
|
out = capsys.readouterr().out.strip()
|
||||||
assert len(accs) == 1
|
payload = json.loads(out.splitlines()[0])
|
||||||
assert accs[0]["url"] == JIANGCHANG_URL
|
assert payload["success"] is False
|
||||||
|
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
|
||||||
|
assert find_accounts(platform_key="unreg_meta_plat") == []
|
||||||
|
count = clean_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'unreg_meta_plat'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
def test_invalid_platform_key_no_register(self, clean_db, capsys):
|
def test_invalid_platform_key_no_register(self, clean_db, capsys):
|
||||||
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
|
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
|
||||||
@@ -382,7 +432,7 @@ class TestAutoRegisterFromCallerMeta:
|
|||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
assert enabled == 0
|
assert enabled == 0
|
||||||
|
|
||||||
def test_auto_register_idempotent(self, clean_db):
|
def test_caller_meta_does_not_insert_idempotent(self, clean_db, capsys):
|
||||||
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
|
from service.account_service import cmd_account_pick_web, PlatformCallerMeta
|
||||||
|
|
||||||
meta = PlatformCallerMeta(
|
meta = PlatformCallerMeta(
|
||||||
@@ -395,4 +445,7 @@ class TestAutoRegisterFromCallerMeta:
|
|||||||
count = clean_db.execute(
|
count = clean_db.execute(
|
||||||
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'idem_plat'"
|
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'idem_plat'"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
assert count == 1
|
assert count == 0
|
||||||
|
out = capsys.readouterr().out.strip().splitlines()[-1]
|
||||||
|
payload = json.loads(out)
|
||||||
|
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -74,3 +74,60 @@ class TestResolvePlatformKeyDynamicParsing:
|
|||||||
clean_db.commit()
|
clean_db.commit()
|
||||||
result = resolve_platform_key_dynamic("bad_alias", conn=clean_db)
|
result = resolve_platform_key_dynamic("bad_alias", conn=clean_db)
|
||||||
assert result.platform_key == "bad_alias"
|
assert result.platform_key == "bad_alias"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlatformKeyFormatAndAmbiguity:
|
||||||
|
def test_format_rejects_mixed_case(self):
|
||||||
|
from util.platforms import is_valid_platform_key_format
|
||||||
|
|
||||||
|
assert is_valid_platform_key_format("haokan") is True
|
||||||
|
assert is_valid_platform_key_format("HaoKan") is False
|
||||||
|
assert is_valid_platform_key_format("hao_kan") is True
|
||||||
|
|
||||||
|
def test_deepseek_display_resolves_to_key(self, clean_db):
|
||||||
|
from util.platforms import resolve_platform_key_dynamic
|
||||||
|
|
||||||
|
result = resolve_platform_key_dynamic("DeepSeek", conn=clean_db)
|
||||||
|
assert result.ok
|
||||||
|
assert result.platform_key == "deepseek"
|
||||||
|
|
||||||
|
def test_douyin_alias_still_unique(self, clean_db):
|
||||||
|
from util.platforms import resolve_platform_key_dynamic
|
||||||
|
|
||||||
|
result = resolve_platform_key_dynamic("抖音", conn=clean_db)
|
||||||
|
assert result.platform_key == "douyin"
|
||||||
|
|
||||||
|
def test_wechat_key_wins(self, clean_db):
|
||||||
|
from util.platforms import resolve_platform_key_dynamic
|
||||||
|
|
||||||
|
result = resolve_platform_key_dynamic("wechat", conn=clean_db)
|
||||||
|
assert result.ok
|
||||||
|
assert result.platform_key == "wechat"
|
||||||
|
|
||||||
|
def test_gongzhonghao_is_ambiguous(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 == "AMBIGUOUS_PLATFORM"
|
||||||
|
assert "wechat" in (result.message or "")
|
||||||
|
assert "wechat_mp" in (result.message or "")
|
||||||
|
|
||||||
|
def test_unregistered_lookalike_key(self, clean_db):
|
||||||
|
from util.platforms import resolve_platform_key_dynamic
|
||||||
|
|
||||||
|
result = resolve_platform_key_dynamic("HaoKan", conn=clean_db)
|
||||||
|
assert result.error_code == "INVALID_PLATFORM_KEY"
|
||||||
|
assert "platform ensure" in (result.message or "")
|
||||||
|
|
||||||
|
def test_unregistered_valid_key_not_inserted(self, clean_db):
|
||||||
|
from util.platforms import resolve_platform_key_dynamic
|
||||||
|
|
||||||
|
result = resolve_platform_key_dynamic("hao_kan", conn=clean_db)
|
||||||
|
assert result.error_code == "PLATFORM_NOT_REGISTERED"
|
||||||
|
assert "已拒绝写入" in (result.message or "")
|
||||||
|
count = clean_db.execute(
|
||||||
|
"SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ class TestRowOpenActions(unittest.TestCase):
|
|||||||
self.assertFalse(reveal.get("replaces"))
|
self.assertFalse(reveal.get("replaces"))
|
||||||
self.assertFalse(open_browser.get("replaces"))
|
self.assertFalse(open_browser.get("replaces"))
|
||||||
|
|
||||||
|
def test_platform_create_takes_over_host_add(self) -> None:
|
||||||
|
create = _by_id(_load_actions(), "platform-ensure")
|
||||||
|
self.assertEqual(create.get("replaces"), "create")
|
||||||
|
self.assertEqual(create.get("placements") or [], [])
|
||||||
|
self.assertEqual(create.get("label"), "新增平台")
|
||||||
|
self.assertEqual((create.get("bind") or {}).get("tables"), ["platforms"])
|
||||||
|
props = (create.get("inputSchema") or {}).get("properties") or {}
|
||||||
|
self.assertEqual(set(props), {"platform_key", "display_name", "default_url"})
|
||||||
|
args = (create.get("entrypoint") or {}).get("args") or []
|
||||||
|
self.assertIn("--key", args)
|
||||||
|
self.assertIn("--display-name", args)
|
||||||
|
|
||||||
def test_create_takes_over_host_add_without_row_duplicate(self) -> None:
|
def test_create_takes_over_host_add_without_row_duplicate(self) -> None:
|
||||||
create = _by_id(_load_actions(), "account-add-web")
|
create = _by_id(_load_actions(), "account-add-web")
|
||||||
self.assertEqual(create.get("replaces"), "create")
|
self.assertEqual(create.get("replaces"), "create")
|
||||||
@@ -66,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