Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a20e7fc69 | |||
| 097dde0ebb | |||
| 718aeefd44 | |||
| 123195fcd9 | |||
| ba991a2462 | |||
| e9455cbb86 | |||
| 87877010ac | |||
| 0f54204a61 | |||
| f48e8aa75d | |||
| f5bab01421 | |||
| cbbec7cdfc | |||
| 5f0d9e45f8 | |||
| 7bac647673 | |||
| 8d36e193b4 | |||
| e441b0c58e | |||
| 12b1fec0b9 | |||
| f7f9555683 | |||
| 549869b2bb |
30
SKILL.md
30
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.0
|
version: 2.0.23
|
||||||
author: 深圳匠厂科技有限公司
|
author: 深圳匠厂科技有限公司
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
@@ -27,17 +27,23 @@ allowed-tools:
|
|||||||
|
|
||||||
## 何时使用本技能
|
## 何时使用本技能
|
||||||
|
|
||||||
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。
|
1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行)
|
||||||
2. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
|
2. **登记网页/RPA 账号**:`account add-web` — 平台 + 账号名称 + 可选登录账号即可;环境/角色/登录策略跟平台默认。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。
|
||||||
3. **查看/列出账号**:`account get/list` — JSON 输出。
|
3. **复制账号**:`account copy <id>` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。
|
||||||
4. **为 RPA 挑选账号**:`account pick-web` — 按环境/租户/角色筛选,可带 lease。
|
4. **编辑账号**:`account update --id <id>` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。
|
||||||
5. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
5. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
|
||||||
6. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。
|
6. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
|
||||||
7. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
7. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
|
||||||
8. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
8. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
|
||||||
9. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
9. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
|
||||||
10. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
10. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
|
||||||
11. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
11. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
|
||||||
|
12. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
|
||||||
|
13. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
|
||||||
|
14. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
|
||||||
|
15. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
|
||||||
|
16. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
|
||||||
|
17. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
|
||||||
|
|
||||||
## CLI 调用
|
## CLI 调用
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# assets
|
# assets
|
||||||
|
|
||||||
|
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开目录 / 打开浏览器)。
|
||||||
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
|
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
|
||||||
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。
|
||||||
|
|
||||||
|
|||||||
210
assets/actions.json
Normal file
210
assets/actions.json
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"skill": "account-manager",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "account-add-web",
|
||||||
|
"label": "新增账号",
|
||||||
|
"description": "登记网页/RPA 账号并自动创建浏览器 Profile 目录(走 account add-web,非宿主直写库)。",
|
||||||
|
"replaces": "create",
|
||||||
|
"placements": [],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "form",
|
||||||
|
"bind": {
|
||||||
|
"tables": ["accounts"]
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["platform_key", "account_label"],
|
||||||
|
"properties": {
|
||||||
|
"platform_key": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "平台",
|
||||||
|
"display_type": "lookup",
|
||||||
|
"lookup": {
|
||||||
|
"ref_table": "platforms",
|
||||||
|
"value_column": "platform_key",
|
||||||
|
"label_column": "display_name",
|
||||||
|
"filter_column": "enabled",
|
||||||
|
"filter_value": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"account_label": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "账号名称"
|
||||||
|
},
|
||||||
|
"login_id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "登录账号"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "account",
|
||||||
|
"args": [
|
||||||
|
"add-web",
|
||||||
|
"--platform", "{{platform_key}}",
|
||||||
|
"--label", "{{account_label}}",
|
||||||
|
"--login-id", "{{login_id}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "account-update",
|
||||||
|
"label": "编辑账号",
|
||||||
|
"description": "只改名称、登录账号、标识类型、入口链接和启用状态。",
|
||||||
|
"replaces": "update",
|
||||||
|
"placements": [],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "form",
|
||||||
|
"bind": {
|
||||||
|
"tables": ["accounts"],
|
||||||
|
"inputMapping": {
|
||||||
|
"accountId": "$row.$pk",
|
||||||
|
"account_label": "$row.account_label",
|
||||||
|
"login_id": "$row.login_id",
|
||||||
|
"login_id_type": "$row.login_id_type",
|
||||||
|
"url": "$row.url",
|
||||||
|
"status": "$row.status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["accountId", "account_label", "status"],
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "编号",
|
||||||
|
"readOnly": true
|
||||||
|
},
|
||||||
|
"account_label": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "账号名称"
|
||||||
|
},
|
||||||
|
"login_id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "登录账号"
|
||||||
|
},
|
||||||
|
"login_id_type": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "登录标识类型",
|
||||||
|
"enum": ["unknown", "phone", "email", "username", "customer_code", "api_account"]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "入口链接",
|
||||||
|
"display_type": "url"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "状态",
|
||||||
|
"enum": ["active", "disabled", "needs_review"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "account",
|
||||||
|
"args": [
|
||||||
|
"update",
|
||||||
|
"--id", "{{accountId}}",
|
||||||
|
"--label", "{{account_label}}",
|
||||||
|
"--login-id", "{{login_id}}",
|
||||||
|
"--login-id-type", "{{login_id_type}}",
|
||||||
|
"--url", "{{url}}",
|
||||||
|
"--status", "{{status}}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "account-copy",
|
||||||
|
"label": "复制",
|
||||||
|
"description": "复制为同平台新账号:新空 Profile,登录账号留空,名称自动加「副本」。",
|
||||||
|
"placements": ["row"],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "direct",
|
||||||
|
"bind": {
|
||||||
|
"tables": ["accounts"],
|
||||||
|
"inputMapping": { "accountId": "$row.$pk" }
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["accountId"],
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "账号 ID",
|
||||||
|
"readOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "account",
|
||||||
|
"args": ["copy", "{{accountId}}"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reveal-profile",
|
||||||
|
"label": "打开目录",
|
||||||
|
"description": "在系统文件管理器中打开该账号的浏览器 Profile 目录。",
|
||||||
|
"placements": ["row"],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "direct",
|
||||||
|
"bind": {
|
||||||
|
"tables": ["accounts"],
|
||||||
|
"inputMapping": { "accountId": "$row.$pk" }
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["accountId"],
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "账号 ID",
|
||||||
|
"description": "accounts 表主键",
|
||||||
|
"readOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "account",
|
||||||
|
"args": ["reveal-profile", "{{accountId}}"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "open-browser",
|
||||||
|
"label": "打开浏览器",
|
||||||
|
"description": "用该账号持久化 Profile 直接打开系统 Chrome/Edge,仅供肉眼核对(不做登录判定)。与打开目录同为一点即开。",
|
||||||
|
"placements": ["row"],
|
||||||
|
"executionProfile": "sync",
|
||||||
|
"interaction": "direct",
|
||||||
|
"bind": {
|
||||||
|
"tables": ["accounts"],
|
||||||
|
"inputMapping": { "accountId": "$row.$pk" }
|
||||||
|
},
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["accountId"],
|
||||||
|
"properties": {
|
||||||
|
"accountId": {
|
||||||
|
"type": "number",
|
||||||
|
"title": "账号 ID",
|
||||||
|
"description": "accounts 表主键",
|
||||||
|
"readOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "cli",
|
||||||
|
"command": "account",
|
||||||
|
"args": ["open", "{{accountId}}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@
|
|||||||
|
|
||||||
## 子命令速查(按业务流程)
|
## 子命令速查(按业务流程)
|
||||||
|
|
||||||
|
### 0. 运行时初始化(宿主标准)
|
||||||
|
|
||||||
|
| CLI | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `init-db` | 幂等初始化本地 SQLite、schema 迁移与数据管理中文元数据(`_jiangchang_*`)。成功输出 JSON:`{"ok":true,"skill":"account-manager","db_path":"..."}`。**匠厂安装/升级技能时会调用本命令**;业务命令内部也会触发同等 `init_db()`。 |
|
||||||
|
|
||||||
### A. 平台管理
|
### A. 平台管理
|
||||||
|
|
||||||
| CLI | 说明 |
|
| CLI | 说明 |
|
||||||
@@ -37,16 +43,21 @@
|
|||||||
|
|
||||||
| CLI | 说明 |
|
| CLI | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `account add-web --platform <p> [--login-id <id>] [--login-id-type <type>] [...]` | 创建网页/RPA 账号;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint <fp>` |
|
| `account add-web --platform <p> [--login-id <id>] [--login-id-type <type>] [...]` | 创建网页/RPA 账号;省略 `--login-id-type` 时按登录账号推断;可选 `--auth-strategy` / `--session-persistent 0\|1` / `--device-fingerprint <fp>` |
|
||||||
|
| `account copy <id>` | 复制为同平台新账号(新空 Profile,名称加「副本」,不拷登录账号/凭证/设备指纹) |
|
||||||
|
| `account update --id <id> [--label] [--login-id] [--login-id-type] [--url] [--status]` | 更新数据管理编辑表单上的短字段 |
|
||||||
| `account add-secret --platform <p> --credential-type <t> --secret-storage env\|windows_credential\|none\|local_encrypted` | 创建凭据;`local_encrypted` **必须** `--secret-stdin` |
|
| `account add-secret --platform <p> --credential-type <t> --secret-storage env\|windows_credential\|none\|local_encrypted` | 创建凭据;`local_encrypted` **必须** `--secret-stdin` |
|
||||||
| `account get <id> [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) |
|
| `account get <id> [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) |
|
||||||
|
| `account resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>] [--label <备注>] [--query <…>]` | **用人话定位一条账号**(成功输出同 `get`);多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT` |
|
||||||
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
|
| `account get-credential <account_id> [--reveal --lease-token <token>]` | v2:**按账号 id** 取活跃凭据;`--reveal` 必须配合合法 lease |
|
||||||
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>] [--limit N]` | 输出账号 JSON **数组** |
|
| `account list [--platform <p>] [...] [--login-id <id>] [--label <label>] [--limit N]` | 输出账号 JSON **数组**;可按登录标识/备注筛选 |
|
||||||
| `account pick-web --platform <p> [--environment <env>] [--tenant-id <t>] [--role <role>] [--lease] [--ttl-sec 900] [--holder <h>] [--purpose <purpose>]` | 挑选网页自动化候选;返回 JSON 含 `auth_strategy` / `session_persistent` / `device_fingerprint` |
|
| `account pick-web --platform <p> [...] [--login-id <…>] [--label <…>] [--lease] [...] [--ensure]` | 挑选网页自动化候选;可按手机号/用户名/备注精确筛选;`--ensure` 等同 `ensure-web` |
|
||||||
|
| `account ensure-web --platform <p> [--login-id <…>] [--label <…>] [...] [--lease]` | **有则 pick、无则 add-web 再 pick**;指定 `--login-id`/`--label` 时按该标识筛选/创建;均被租约占用时**不新建**,返回 `ERROR:LEASE_CONFLICT` |
|
||||||
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
|
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
|
||||||
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
|
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
|
||||||
| `account mark-used <id>` | 标记已使用 |
|
| `account mark-used <id>` | 标记已使用 |
|
||||||
| `account open <id>` | 打开持久化浏览器 profile(仅查看) |
|
| `account open <id>` | 本进程直接打开系统 Chrome/Edge(独立 profile;最大化;跳过首次欢迎页;打开平台 URL) |
|
||||||
|
| `account reveal-profile <id>` | 在系统文件管理器中打开该账号 `profile_dir` |
|
||||||
|
|
||||||
### D. 加密备份与迁移(v2)
|
### D. 加密备份与迁移(v2)
|
||||||
|
|
||||||
@@ -66,7 +77,8 @@
|
|||||||
| `lease release <lease_token>` | 释放租约 |
|
| `lease release <lease_token>` | 释放租约 |
|
||||||
| `lease list [--active]` | 列出租约 |
|
| `lease list [--active]` | 列出租约 |
|
||||||
| `lease cleanup` | 清理过期租约 |
|
| `lease cleanup` | 清理过期租约 |
|
||||||
| `account open <id>` / `open <id>` | 浏览器查看 |
|
| `account open <id>` / `open <id>` | 打开浏览器 |
|
||||||
|
| `account reveal-profile <id>` | 打开目录 |
|
||||||
|
|
||||||
### 兼容入口(旧版别名)
|
### 兼容入口(旧版别名)
|
||||||
|
|
||||||
@@ -99,6 +111,19 @@ python main.py account add-web \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## account copy / update(数据管理)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py account copy 3
|
||||||
|
python main.py account update --id 4 --label "抖音 备用" --login-id 13900001111 --status active
|
||||||
|
```
|
||||||
|
|
||||||
|
- `copy`:复制平台、环境、角色、入口链接、登录策略;**新建空 Profile**;登录账号留空;名称自动「副本」/「副本 2」。不复制凭证、设备指纹、会话状态。
|
||||||
|
- `update`:只改名称、登录账号、标识类型、入口链接、状态。空入口链接回退为平台默认 URL。
|
||||||
|
- 数据管理「新增」只填平台 / 账号名称 / 登录账号;「编辑」走 `replaces: update` 短表单;行内「复制」为 `interaction: direct`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## account add-secret(`local_encrypted` 示例)
|
## account add-secret(`local_encrypted` 示例)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
| `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 |
|
||||||
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
|
| `ERROR:NO_ACCOUNT` | `pick-web` / `resolve` 无符合条件的可用账号(含均被 active 未过期 lease 占用) |
|
||||||
|
| `ERROR:AMBIGUOUS_ACCOUNT` | `resolve` / 带 `--login-id`/`--label` 的 `pick-web`:匹配到多条,需改用 `--id` 或补 `--platform` |
|
||||||
|
| `ERROR:LEASE_CONFLICT` | `ensure-web` / `pick-web --ensure`:已有账号但均被租约占用(**不会**自动再建);或同账号重复 acquire lease |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
|
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
|
||||||
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
|
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
|
||||||
|
| `ERROR:REVEAL_PROFILE_FAILED` | `account reveal-profile` 无法打开目录(系统调用失败) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
| **A. CLI subprocess(v1 起)** | 任意业务 skill;一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
|
| **A. CLI subprocess(v1 起)** | 任意业务 skill;一次性取账号 JSON / 凭据 / lease | `subprocess.run([sys.executable, f"{skill_root}/scripts/main.py", "account", "pick-web", ...])` |
|
||||||
| **B. rpa_helpers 同进程 import(v2)** | 需要 Playwright 上下文内完成登录与页面操作 | `sys.path.insert(0, f"{account_manager_root}/scripts"); from service.rpa_helpers import ensure_logged_in` |
|
| **B. rpa_helpers 同进程 import(v2)** | 需要 Playwright 上下文内完成登录与页面操作 | `sys.path.insert(0, f"{account_manager_root}/scripts"); from service.rpa_helpers import ensure_logged_in` |
|
||||||
|
|
||||||
常见组合:**CLI** 负责 pick / lease / `get-credential --reveal`;**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
常见组合:**CLI** 负责 `ensure-web`(或 `pick-web --ensure`)/ lease / `get-credential --reveal`;**rpa_helpers** 负责 `launch_browser_with_profile` + `ensure_logged_in`。详见 [`RPA_HELPERS.md`](RPA_HELPERS.md)。
|
||||||
|
|
||||||
|
业务技能首启推荐:`account ensure-web --platform <p> [--label ...] [--lease --holder <skill>]`,避免因库中尚无账号而 `NO_ACCOUNT`。登记账号 ≠ 站点已登录。
|
||||||
|
|
||||||
|
指定某人读账号时:`account resolve --platform <p> --login-id <手机号或用户名>`,或 `pick-web --platform <p> --login-id <…> --lease`;多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT`。
|
||||||
|
|
||||||
## 推荐调用方式
|
## 推荐调用方式
|
||||||
|
|
||||||
@@ -26,11 +30,15 @@
|
|||||||
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
|
| 取单条账号 JSON | `account get <id>` | **单行 JSON 对象**(无固定 `success` 字段) |
|
||||||
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
|
| 批量账号 JSON | `account list ...` | **单行 JSON 数组** |
|
||||||
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
|
| 网页自动化候选 | `account pick-web ...` | **单行 JSON 对象** |
|
||||||
|
| 有则用无则建 | `account ensure-web ...` / `pick-web --ensure` | **同 pick-web**;本次新建时多 `account_ensured: true` |
|
||||||
|
| 用人话定位账号 | `account resolve --login-id …` / `--label …` / `--query …` | **同 account get**(单行账号对象) |
|
||||||
| 取加密凭据明文 | `account get-credential <id> --reveal --lease-token <t>` | **单行 JSON**,含 `plaintext`(若可解密) |
|
| 取加密凭据明文 | `account get-credential <id> --reveal --lease-token <t>` | **单行 JSON**,含 `plaintext`(若可解密) |
|
||||||
| 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**(stderr 有警告行) |
|
| 备份加密凭据 | `account export-credentials --plaintext` | **JSON 数组**(stderr 有警告行) |
|
||||||
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) |
|
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) |
|
||||||
| 初始化 master key | `account init-master-key [--print\|--from-env\|--rotate]` | **单行 JSON** |
|
| 初始化 master key | `account init-master-key [--print\|--from-env\|--rotate]` | **单行 JSON** |
|
||||||
| 新增网页账号 | `account add-web ...` | `{"success": true, "data": {...}}` |
|
| 新增网页账号 | `account add-web ...` | `{"success": true, "data": {...}}` |
|
||||||
|
| 复制网页账号 | `account copy <id>` | `{"success": true, "message": "已复制为「…」", "data": {...}}` |
|
||||||
|
| 编辑网页账号 | `account update --id <id> ...` | `{"success": true, "message": "已保存", "data": {...}}` |
|
||||||
| 新增凭据 | `account add-secret ...` | `{"success": true, "data": {...}}` |
|
| 新增凭据 | `account add-secret ...` | `{"success": true, "data": {...}}` |
|
||||||
| 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` |
|
| 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` |
|
||||||
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上对应新式命令 |
|
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上对应新式命令 |
|
||||||
|
|||||||
@@ -58,4 +58,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.0
|
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.23
|
||||||
|
|||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
|
||||||
|
# Fernet 加密(local_encrypted 凭据、master key、CLI 启动 import)依赖此包。
|
||||||
|
cryptography>=42.0.0,<45
|
||||||
@@ -4,7 +4,7 @@ CLI entry point: argv dispatch for account-manager v2.
|
|||||||
|
|
||||||
Subcommand tree:
|
Subcommand tree:
|
||||||
platform list/get/ensure
|
platform list/get/ensure
|
||||||
account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open
|
account add-web|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|list|cleanup
|
||||||
@@ -23,6 +23,9 @@ from service.account_crypto_commands import (
|
|||||||
from service.account_service import (
|
from service.account_service import (
|
||||||
cmd_account_add_secret,
|
cmd_account_add_secret,
|
||||||
cmd_account_add_web,
|
cmd_account_add_web,
|
||||||
|
cmd_account_copy,
|
||||||
|
cmd_account_ensure_web,
|
||||||
|
cmd_account_update,
|
||||||
cmd_account_get,
|
cmd_account_get,
|
||||||
cmd_account_get_credential,
|
cmd_account_get_credential,
|
||||||
cmd_account_list,
|
cmd_account_list,
|
||||||
@@ -30,12 +33,14 @@ from service.account_service import (
|
|||||||
cmd_account_mark_session,
|
cmd_account_mark_session,
|
||||||
cmd_account_mark_used,
|
cmd_account_mark_used,
|
||||||
cmd_account_pick_web,
|
cmd_account_pick_web,
|
||||||
|
cmd_account_resolve,
|
||||||
cmd_credential_pick,
|
cmd_credential_pick,
|
||||||
cmd_credential_resolve,
|
cmd_credential_resolve,
|
||||||
cmd_delete_by_id,
|
cmd_delete_by_id,
|
||||||
cmd_delete_by_platform,
|
cmd_delete_by_platform,
|
||||||
cmd_delete_by_platform_phone,
|
cmd_delete_by_platform_phone,
|
||||||
cmd_get,
|
cmd_get,
|
||||||
|
cmd_init_db,
|
||||||
cmd_lease_cleanup,
|
cmd_lease_cleanup,
|
||||||
cmd_lease_list,
|
cmd_lease_list,
|
||||||
cmd_lease_release,
|
cmd_lease_release,
|
||||||
@@ -46,7 +51,7 @@ from service.account_service import (
|
|||||||
cmd_platform_get,
|
cmd_platform_get,
|
||||||
cmd_platform_list,
|
cmd_platform_list,
|
||||||
)
|
)
|
||||||
from service.browser_service import cmd_open
|
from service.browser_service import cmd_open, cmd_reveal_profile
|
||||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||||
from util.logging_config import get_skill_logger, setup_skill_logging
|
from util.logging_config import get_skill_logger, setup_skill_logging
|
||||||
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
|
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
|
||||||
@@ -58,6 +63,9 @@ from util.runtime_paths import _maybe_print_paths_debug_cli
|
|||||||
|
|
||||||
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
||||||
|
|
||||||
|
运行时 / 数据管理:
|
||||||
|
init-db # 幂等初始化本地库与数据管理中文元数据(宿主安装/升级会调用)
|
||||||
|
|
||||||
平台管理:
|
平台管理:
|
||||||
platform list [--domain logistics|llm|content]
|
platform list [--domain logistics|llm|content]
|
||||||
platform get <platform>
|
platform get <platform>
|
||||||
@@ -65,11 +73,14 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
|||||||
[--url <url>] [--auth-strategy <s>]
|
[--url <url>] [--auth-strategy <s>]
|
||||||
|
|
||||||
账号管理:
|
账号管理:
|
||||||
account add-web --platform <p> --login-id <id> --login-id-type <type>
|
account add-web --platform <p> [--login-id <id>] [--login-id-type <type>]
|
||||||
[--label <label>] [--tenant-id <t>] [--environment <env>]
|
[--label <label>] [--tenant-id <t>] [--environment <env>]
|
||||||
[--role <role>] [--provider-code <code>] [--url <url>]
|
[--role <role>] [--provider-code <code>] [--url <url>]
|
||||||
[--extra-json <json>] [--profile-dir <path>]
|
[--extra-json <json>] [--profile-dir <path>]
|
||||||
[--auth-strategy <s>] [--session-persistent 0|1] [--device-fingerprint <fp>]
|
[--auth-strategy <s>] [--session-persistent 0|1] [--device-fingerprint <fp>]
|
||||||
|
account copy <id>
|
||||||
|
account update --id <id> [--label <label>] [--login-id <id>]
|
||||||
|
[--login-id-type <type>] [--url <url>] [--status active|disabled|needs_review]
|
||||||
account add-secret --platform <p> --credential-type <type>
|
account add-secret --platform <p> --credential-type <type>
|
||||||
--secret-storage env|windows_credential|none|local_encrypted
|
--secret-storage env|windows_credential|none|local_encrypted
|
||||||
[--secret-ref <env_var>] [--secret-stdin]
|
[--secret-ref <env_var>] [--secret-stdin]
|
||||||
@@ -77,15 +88,28 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
|||||||
[--environment <env>] [--role <role>]
|
[--environment <env>] [--role <role>]
|
||||||
account get <id> [--with-credentials]
|
account get <id> [--with-credentials]
|
||||||
account get-credential <account_id> [--reveal --lease-token <token>]
|
account get-credential <account_id> [--reveal --lease-token <token>]
|
||||||
|
account resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>]
|
||||||
|
[--label <备注>] [--query <id或login_id或备注>]
|
||||||
|
# 用人话定位一条账号(成功输出同 account get)
|
||||||
account list [--platform <p>] [--environment <env>]
|
account list [--platform <p>] [--environment <env>]
|
||||||
[--tenant-id <t>] [--role <role>] [--limit N]
|
[--tenant-id <t>] [--role <role>]
|
||||||
|
[--login-id <id>] [--label <label>] [--limit N]
|
||||||
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
|
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
|
||||||
[--role <role>] [--lease] [--ttl-sec 900]
|
[--role <role>] [--login-id <手机号/用户名>] [--label <备注>]
|
||||||
|
[--lease] [--ttl-sec 900]
|
||||||
[--holder <holder>] [--purpose <purpose>]
|
[--holder <holder>] [--purpose <purpose>]
|
||||||
|
[--ensure] # 同 ensure-web:无账号时自动登记再 pick
|
||||||
|
account ensure-web --platform <p> [--login-id <手机号/用户名>] [--label <备注>]
|
||||||
|
[--environment <env>] [--tenant-id <t>] [--role <role>]
|
||||||
|
[--lease] [--ttl-sec 900] [--holder <h>] [--purpose <p>]
|
||||||
|
# 有账号则 pick;零账号则按参数 add-web 后再 pick
|
||||||
|
# 有账号但均被租约占用时不新建,返回 LEASE_CONFLICT
|
||||||
|
# 指定 --login-id/--label 时按该人读标识筛选/创建
|
||||||
account mark-session <id> --session-status <status>
|
account mark-session <id> --session-status <status>
|
||||||
account mark-error <id> --code <code> --message <msg>
|
account mark-error <id> --code <code> --message <msg>
|
||||||
account mark-used <id>
|
account mark-used <id>
|
||||||
account open <id>
|
account open <id>
|
||||||
|
account reveal-profile <id>
|
||||||
account init-master-key [--print|--from-env|--rotate]
|
account init-master-key [--print|--from-env|--rotate]
|
||||||
account export-credentials --plaintext
|
account export-credentials --plaintext
|
||||||
account import-credentials [--replace-all] < backup.json
|
account import-credentials [--replace-all] < backup.json
|
||||||
@@ -100,8 +124,9 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
|||||||
lease list [--active]
|
lease list [--active]
|
||||||
lease cleanup
|
lease cleanup
|
||||||
|
|
||||||
浏览器:
|
浏览器 / Profile:
|
||||||
account open <id>
|
account open <id>
|
||||||
|
account reveal-profile <id> # 在文件管理器中打开 profile_dir
|
||||||
open <id> # 兼容别名
|
open <id> # 兼容别名
|
||||||
|
|
||||||
兼容入口(旧版):
|
兼容入口(旧版):
|
||||||
@@ -203,6 +228,10 @@ def main() -> None:
|
|||||||
# Determine if first arg is a platform key (for legacy shorthand)
|
# Determine if first arg is a platform key (for legacy shorthand)
|
||||||
plat_from_first = resolve_platform_key(cmd)
|
plat_from_first = resolve_platform_key(cmd)
|
||||||
|
|
||||||
|
# ---- init-db(宿主 install/upgrade 标准入口)----
|
||||||
|
if cmd == "init-db":
|
||||||
|
sys.exit(cmd_init_db())
|
||||||
|
|
||||||
# ---- platform subcommand ----
|
# ---- platform subcommand ----
|
||||||
if cmd == "platform":
|
if cmd == "platform":
|
||||||
if len(av) < 3:
|
if len(av) < 3:
|
||||||
@@ -246,12 +275,21 @@ def main() -> None:
|
|||||||
# ---- account subcommand ----
|
# ---- account subcommand ----
|
||||||
elif cmd == "account":
|
elif cmd == "account":
|
||||||
if len(av) < 3:
|
if len(av) < 3:
|
||||||
print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / get / …)。")
|
print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / ensure-web / get / …)。")
|
||||||
print(_USAGE)
|
print(_USAGE)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sub = av[2]
|
sub = av[2]
|
||||||
if sub == "add-web":
|
if sub == "add-web":
|
||||||
_cmd_add_web(av)
|
_cmd_add_web(av)
|
||||||
|
elif sub == "copy":
|
||||||
|
if len(av) < 4:
|
||||||
|
print("ERROR:CLI_BAD_ARGS account copy 需要 <id>。")
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_account_copy(av[3])
|
||||||
|
elif sub == "update":
|
||||||
|
_cmd_account_update(av)
|
||||||
|
elif sub == "ensure-web":
|
||||||
|
_cmd_ensure_web(av)
|
||||||
elif sub == "add-secret":
|
elif sub == "add-secret":
|
||||||
_cmd_add_secret(av)
|
_cmd_add_secret(av)
|
||||||
elif sub == "get-credential":
|
elif sub == "get-credential":
|
||||||
@@ -268,6 +306,8 @@ def main() -> None:
|
|||||||
cmd_account_get(aid, include_credentials=with_creds)
|
cmd_account_get(aid, include_credentials=with_creds)
|
||||||
elif sub == "list":
|
elif sub == "list":
|
||||||
_cmd_account_list(av)
|
_cmd_account_list(av)
|
||||||
|
elif sub == "resolve":
|
||||||
|
_cmd_account_resolve(av)
|
||||||
elif sub == "pick-web":
|
elif sub == "pick-web":
|
||||||
_cmd_pick_web_new(av)
|
_cmd_pick_web_new(av)
|
||||||
elif sub == "mark-session":
|
elif sub == "mark-session":
|
||||||
@@ -296,6 +336,11 @@ def main() -> None:
|
|||||||
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
cmd_open(av[3])
|
cmd_open(av[3])
|
||||||
|
elif sub == "reveal-profile":
|
||||||
|
if len(av) < 4:
|
||||||
|
print("ERROR:CLI_BAD_ARGS account reveal-profile 需要 <id>。")
|
||||||
|
sys.exit(1)
|
||||||
|
cmd_reveal_profile(av[3])
|
||||||
elif sub == "init-master-key":
|
elif sub == "init-master-key":
|
||||||
cmd_account_init_master_key(av)
|
cmd_account_init_master_key(av)
|
||||||
elif sub == "export-credentials":
|
elif sub == "export-credentials":
|
||||||
@@ -434,7 +479,7 @@ def main() -> None:
|
|||||||
def _cmd_add_web(argv: list[str]) -> None:
|
def _cmd_add_web(argv: list[str]) -> None:
|
||||||
plat = _get_opt(argv, "--platform", "")
|
plat = _get_opt(argv, "--platform", "")
|
||||||
login_id = _get_opt(argv, "--login-id")
|
login_id = _get_opt(argv, "--login-id")
|
||||||
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
|
login_id_type = _get_opt(argv, "--login-id-type")
|
||||||
label = _get_opt(argv, "--label")
|
label = _get_opt(argv, "--label")
|
||||||
tenant_id = _get_opt(argv, "--tenant-id")
|
tenant_id = _get_opt(argv, "--tenant-id")
|
||||||
environment = _get_opt(argv, "--environment", "production")
|
environment = _get_opt(argv, "--environment", "production")
|
||||||
@@ -478,6 +523,34 @@ def _cmd_add_web(argv: list[str]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_account_update(argv: list[str]) -> None:
|
||||||
|
from db.accounts_repo import _UNSET
|
||||||
|
|
||||||
|
aid = _get_opt(argv, "--id")
|
||||||
|
if not aid and len(argv) >= 4 and not str(argv[3]).startswith("-"):
|
||||||
|
aid = argv[3]
|
||||||
|
label = _get_opt(argv, "--label")
|
||||||
|
login_id_type = _get_opt(argv, "--login-id-type")
|
||||||
|
status = _get_opt(argv, "--status")
|
||||||
|
login_id = _UNSET
|
||||||
|
if "--login-id" in argv:
|
||||||
|
login_id = _get_opt(argv, "--login-id") or ""
|
||||||
|
url = _UNSET
|
||||||
|
if "--url" in argv:
|
||||||
|
url = _get_opt(argv, "--url") or ""
|
||||||
|
if not aid:
|
||||||
|
print("ERROR:CLI_BAD_ARGS account update 需要 --id <id>。")
|
||||||
|
return
|
||||||
|
cmd_account_update(
|
||||||
|
aid,
|
||||||
|
label=label,
|
||||||
|
login_id=login_id,
|
||||||
|
login_id_type=login_id_type,
|
||||||
|
url=url,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cmd_get_credential(argv: list[str]) -> None:
|
def _cmd_get_credential(argv: list[str]) -> None:
|
||||||
aid_raw = argv[3]
|
aid_raw = argv[3]
|
||||||
try:
|
try:
|
||||||
@@ -524,35 +597,112 @@ def _cmd_account_list(argv: list[str]) -> None:
|
|||||||
env = _get_opt(argv, "--environment")
|
env = _get_opt(argv, "--environment")
|
||||||
tenant = _get_opt(argv, "--tenant-id")
|
tenant = _get_opt(argv, "--tenant-id")
|
||||||
role = _get_opt(argv, "--role")
|
role = _get_opt(argv, "--role")
|
||||||
|
login_id = _get_opt(argv, "--login-id")
|
||||||
|
label = _get_opt(argv, "--label")
|
||||||
limit = _get_int_opt(argv, "--limit", 200)
|
limit = _get_int_opt(argv, "--limit", 200)
|
||||||
cmd_account_list(
|
cmd_account_list(
|
||||||
platform_input=plat,
|
platform_input=plat,
|
||||||
environment=env,
|
environment=env,
|
||||||
tenant_id=tenant,
|
tenant_id=tenant,
|
||||||
role=role,
|
role=role,
|
||||||
|
login_id=login_id,
|
||||||
|
label=label,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_account_resolve(argv: list[str]) -> None:
|
||||||
|
cmd_account_resolve(
|
||||||
|
account_id=_get_opt(argv, "--id"),
|
||||||
|
platform_input=_get_opt(argv, "--platform"),
|
||||||
|
login_id=_get_opt(argv, "--login-id"),
|
||||||
|
label=_get_opt(argv, "--label"),
|
||||||
|
query=_get_opt(argv, "--query"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cmd_pick_web_new(argv: list[str]) -> None:
|
def _cmd_pick_web_new(argv: list[str]) -> None:
|
||||||
plat = _get_opt(argv, "--platform", "")
|
plat = _get_opt(argv, "--platform", "")
|
||||||
env = _get_opt(argv, "--environment")
|
env = _get_opt(argv, "--environment")
|
||||||
tenant = _get_opt(argv, "--tenant-id")
|
tenant = _get_opt(argv, "--tenant-id")
|
||||||
role = _get_opt(argv, "--role")
|
role = _get_opt(argv, "--role")
|
||||||
|
login_id = _get_opt(argv, "--login-id")
|
||||||
|
label = _get_opt(argv, "--label")
|
||||||
do_lease = _has_flag(argv, "--lease")
|
do_lease = _has_flag(argv, "--lease")
|
||||||
ttl = _get_int_opt(argv, "--ttl-sec", 900)
|
ttl = _get_int_opt(argv, "--ttl-sec", 900)
|
||||||
holder = _get_opt(argv, "--holder")
|
holder = _get_opt(argv, "--holder")
|
||||||
purpose = _get_opt(argv, "--purpose")
|
purpose = _get_opt(argv, "--purpose")
|
||||||
|
do_ensure = _has_flag(argv, "--ensure")
|
||||||
|
|
||||||
if not plat:
|
if not plat:
|
||||||
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
|
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
|
||||||
return
|
return
|
||||||
platform_meta = _parse_platform_caller_meta(argv)
|
platform_meta = _parse_platform_caller_meta(argv)
|
||||||
|
if do_ensure:
|
||||||
|
_cmd_ensure_web(argv)
|
||||||
|
return
|
||||||
cmd_account_pick_web(
|
cmd_account_pick_web(
|
||||||
platform_input=plat,
|
platform_input=plat,
|
||||||
environment=env,
|
environment=env,
|
||||||
tenant_id=tenant,
|
tenant_id=tenant,
|
||||||
role=role,
|
role=role,
|
||||||
|
login_id=login_id,
|
||||||
|
label=label,
|
||||||
|
do_lease=do_lease,
|
||||||
|
ttl_sec=ttl,
|
||||||
|
holder=holder,
|
||||||
|
purpose=purpose,
|
||||||
|
platform_meta=platform_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_ensure_web(argv: list[str]) -> None:
|
||||||
|
plat = _get_opt(argv, "--platform", "")
|
||||||
|
login_id = _get_opt(argv, "--login-id")
|
||||||
|
login_id_type = _get_opt(argv, "--login-id-type")
|
||||||
|
label = _get_opt(argv, "--label")
|
||||||
|
tenant_id = _get_opt(argv, "--tenant-id")
|
||||||
|
environment = _get_opt(argv, "--environment")
|
||||||
|
role = _get_opt(argv, "--role")
|
||||||
|
provider_code = _get_opt(argv, "--provider-code")
|
||||||
|
url = _get_opt(argv, "--url")
|
||||||
|
extra_json_str = _get_opt(argv, "--extra-json")
|
||||||
|
profile_dir_override = _get_opt(argv, "--profile-dir")
|
||||||
|
auth_strategy = _get_opt(argv, "--auth-strategy")
|
||||||
|
session_persistent_raw = _get_opt(argv, "--session-persistent")
|
||||||
|
device_fingerprint = _get_opt(argv, "--device-fingerprint")
|
||||||
|
do_lease = _has_flag(argv, "--lease")
|
||||||
|
ttl = _get_int_opt(argv, "--ttl-sec", 900)
|
||||||
|
holder = _get_opt(argv, "--holder")
|
||||||
|
purpose = _get_opt(argv, "--purpose")
|
||||||
|
|
||||||
|
session_persistent: Optional[int] = None
|
||||||
|
if session_persistent_raw is not None:
|
||||||
|
try:
|
||||||
|
session_persistent = int(session_persistent_raw)
|
||||||
|
except ValueError:
|
||||||
|
print("ERROR:CLI_BAD_ARGS --session-persistent 必须是整数(0 或 1)。")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not plat:
|
||||||
|
print("ERROR:CLI_BAD_ARGS account ensure-web 需要 --platform <platform>。")
|
||||||
|
return
|
||||||
|
platform_meta = _parse_platform_caller_meta(argv)
|
||||||
|
cmd_account_ensure_web(
|
||||||
|
platform_input=plat,
|
||||||
|
login_id=login_id,
|
||||||
|
login_id_type=login_id_type,
|
||||||
|
label=label,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
environment=environment,
|
||||||
|
role=role,
|
||||||
|
provider_code=provider_code,
|
||||||
|
url=url,
|
||||||
|
extra_json_str=extra_json_str,
|
||||||
|
profile_dir_override=profile_dir_override,
|
||||||
|
auth_strategy=auth_strategy,
|
||||||
|
session_persistent=session_persistent,
|
||||||
|
device_fingerprint=device_fingerprint,
|
||||||
do_lease=do_lease,
|
do_lease=do_lease,
|
||||||
ttl_sec=ttl,
|
ttl_sec=ttl,
|
||||||
holder=holder,
|
holder=holder,
|
||||||
|
|||||||
@@ -229,6 +229,26 @@ def get_account_by_id(account_id) -> Optional[dict]:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _append_human_ref_filters(
|
||||||
|
conditions: list[str],
|
||||||
|
params: list,
|
||||||
|
*,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
table_alias: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Optional case-insensitive exact match on login_id / account_label."""
|
||||||
|
prefix = f"{table_alias}." if table_alias else ""
|
||||||
|
lid = (login_id or "").strip()
|
||||||
|
if lid:
|
||||||
|
conditions.append(f"LOWER({prefix}login_id) = LOWER(?)")
|
||||||
|
params.append(lid)
|
||||||
|
lab = (label or "").strip()
|
||||||
|
if lab:
|
||||||
|
conditions.append(f"LOWER({prefix}account_label) = LOWER(?)")
|
||||||
|
params.append(lab)
|
||||||
|
|
||||||
|
|
||||||
def find_accounts(
|
def find_accounts(
|
||||||
platform_key: Optional[str] = None,
|
platform_key: Optional[str] = None,
|
||||||
environment: Optional[str] = None,
|
environment: Optional[str] = None,
|
||||||
@@ -236,6 +256,8 @@ def find_accounts(
|
|||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
provider_code: Optional[str] = None,
|
provider_code: Optional[str] = None,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Find accounts by optional filters. Returns list of dicts."""
|
"""Find accounts by optional filters. Returns list of dicts."""
|
||||||
@@ -263,6 +285,9 @@ def find_accounts(
|
|||||||
if provider_code:
|
if provider_code:
|
||||||
conditions.append("provider_code = ?")
|
conditions.append("provider_code = ?")
|
||||||
params.append(provider_code)
|
params.append(provider_code)
|
||||||
|
_append_human_ref_filters(
|
||||||
|
conditions, params, login_id=login_id, label=label
|
||||||
|
)
|
||||||
|
|
||||||
where = ""
|
where = ""
|
||||||
if conditions:
|
if conditions:
|
||||||
@@ -284,11 +309,48 @@ def find_accounts(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def count_active_accounts(
|
||||||
|
platform_key: str,
|
||||||
|
environment: Optional[str] = None,
|
||||||
|
tenant_id: Optional[str] = None,
|
||||||
|
role: Optional[str] = None,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Count active accounts matching filters (ignores lease occupancy)."""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
conn.row_factory = None
|
||||||
|
cur = conn.cursor()
|
||||||
|
conditions = ["status = 'active'", "platform_key = ?"]
|
||||||
|
params: list = [platform_key]
|
||||||
|
if environment:
|
||||||
|
conditions.append("environment = ?")
|
||||||
|
params.append(environment)
|
||||||
|
if tenant_id:
|
||||||
|
conditions.append("tenant_id = ?")
|
||||||
|
params.append(tenant_id)
|
||||||
|
if role:
|
||||||
|
conditions.append("role = ?")
|
||||||
|
params.append(role)
|
||||||
|
_append_human_ref_filters(
|
||||||
|
conditions, params, login_id=login_id, label=label
|
||||||
|
)
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
cur.execute(f"SELECT COUNT(*) FROM accounts WHERE {where}", params)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row[0] or 0) if row else 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def find_account_ids(
|
def find_account_ids(
|
||||||
platform_key: str,
|
platform_key: str,
|
||||||
environment: Optional[str] = None,
|
environment: Optional[str] = None,
|
||||||
tenant_id: Optional[str] = None,
|
tenant_id: Optional[str] = None,
|
||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""Return account IDs matching filters, excluding those with active leases."""
|
"""Return account IDs matching filters, excluding those with active leases."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
@@ -306,6 +368,9 @@ def find_account_ids(
|
|||||||
if role:
|
if role:
|
||||||
conditions.append("a.role = ?")
|
conditions.append("a.role = ?")
|
||||||
params.append(role)
|
params.append(role)
|
||||||
|
_append_human_ref_filters(
|
||||||
|
conditions, params, login_id=login_id, label=label, table_alias="a"
|
||||||
|
)
|
||||||
|
|
||||||
# Exclude accounts with active non-expired leases
|
# Exclude accounts with active non-expired leases
|
||||||
now = _now_unix()
|
now = _now_unix()
|
||||||
@@ -406,6 +471,58 @@ def update_account(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_account_labels(conn) -> list[str]:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT account_label FROM accounts WHERE account_label IS NOT NULL")
|
||||||
|
return [str(row[0]) for row in cur.fetchall() if row[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def update_account_user_fields(
|
||||||
|
conn,
|
||||||
|
account_id: int,
|
||||||
|
*,
|
||||||
|
account_label: Optional[str] = None,
|
||||||
|
login_id: Any = _UNSET,
|
||||||
|
login_id_type: Optional[str] = None,
|
||||||
|
url: Any = _UNSET,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Patch fields the data-management edit form is allowed to change."""
|
||||||
|
sets: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if account_label is not None:
|
||||||
|
label = str(account_label).strip()
|
||||||
|
if not label:
|
||||||
|
raise ValueError("account_label must not be empty")
|
||||||
|
sets.append("account_label = ?")
|
||||||
|
params.append(label)
|
||||||
|
if login_id is not _UNSET:
|
||||||
|
text = str(login_id).strip() if login_id is not None else ""
|
||||||
|
sets.append("login_id = ?")
|
||||||
|
params.append(text or None)
|
||||||
|
if login_id_type is not None:
|
||||||
|
typed = str(login_id_type).strip() or "unknown"
|
||||||
|
sets.append("login_id_type = ?")
|
||||||
|
params.append(typed)
|
||||||
|
if url is not _UNSET:
|
||||||
|
text = str(url).strip() if url is not None else ""
|
||||||
|
sets.append("url = ?")
|
||||||
|
params.append(text)
|
||||||
|
if status is not None:
|
||||||
|
sets.append("status = ?")
|
||||||
|
params.append(status)
|
||||||
|
if not sets:
|
||||||
|
return
|
||||||
|
sets.append("updated_at = ?")
|
||||||
|
params.append(_now_unix())
|
||||||
|
params.append(int(account_id))
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
|
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sqlite3
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from db.display_metadata import init_display_metadata
|
||||||
from db.schema import SCHEMA_VERSION, ensure_schema
|
from db.schema import SCHEMA_VERSION, ensure_schema
|
||||||
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
|
||||||
@@ -85,6 +86,7 @@ def init_db() -> None:
|
|||||||
ensure_schema(conn)
|
ensure_schema(conn)
|
||||||
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
init_display_metadata(cur)
|
||||||
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|||||||
593
scripts/db/display_metadata.py
Normal file
593
scripts/db/display_metadata.py
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
"""匠厂数据管理展示元数据(_jiangchang_tables / _jiangchang_columns)。
|
||||||
|
|
||||||
|
SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。
|
||||||
|
字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
|
||||||
|
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
|
||||||
|
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
|
||||||
|
|
||||||
|
PLATFORMS_TABLE = "platforms"
|
||||||
|
ACCOUNTS_TABLE = "accounts"
|
||||||
|
CREDENTIALS_TABLE = "credentials"
|
||||||
|
ACCOUNT_LEASES_TABLE = "account_leases"
|
||||||
|
|
||||||
|
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
|
||||||
|
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
|
||||||
|
|
||||||
|
# Data-management lookup: host resolves options_json object into select options.
|
||||||
|
PLATFORM_KEY_LOOKUP_JSON = json.dumps(
|
||||||
|
{
|
||||||
|
"ref_table": "platforms",
|
||||||
|
"value_column": "platform_key",
|
||||||
|
"label_column": "display_name",
|
||||||
|
"filter_column": "enabled",
|
||||||
|
"filter_value": 1,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
ACCOUNT_ID_LOOKUP_JSON = json.dumps(
|
||||||
|
{
|
||||||
|
"ref_table": "accounts",
|
||||||
|
"value_column": "id",
|
||||||
|
"label_column": "account_label",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_options(pairs: tuple[tuple[str, str], ...]) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
[{"value": value, "label": label} for value, label in pairs],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LOGIN_ID_TYPE_OPTIONS_JSON = _enum_options(
|
||||||
|
(
|
||||||
|
("unknown", "未指定"),
|
||||||
|
("phone", "手机号"),
|
||||||
|
("email", "邮箱"),
|
||||||
|
("username", "用户名"),
|
||||||
|
("customer_code", "客户代码"),
|
||||||
|
("api_account", "API 账号"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ENVIRONMENT_OPTIONS_JSON = _enum_options(
|
||||||
|
(
|
||||||
|
("production", "生产"),
|
||||||
|
("simulator", "仿真"),
|
||||||
|
("staging", "预发"),
|
||||||
|
("test", "测试"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ROLE_OPTIONS_JSON = _enum_options(
|
||||||
|
(
|
||||||
|
("default", "默认"),
|
||||||
|
("booking", "订舱"),
|
||||||
|
("tracking", "跟踪"),
|
||||||
|
("api", "API"),
|
||||||
|
("admin", "管理"),
|
||||||
|
("sales", "销售"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ACCOUNT_STATUS_OPTIONS_JSON = _enum_options(
|
||||||
|
(
|
||||||
|
("active", "启用"),
|
||||||
|
("disabled", "停用"),
|
||||||
|
("needs_review", "待复核"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SESSION_STATUS_OPTIONS_JSON = _enum_options(
|
||||||
|
(
|
||||||
|
("unknown", "未知"),
|
||||||
|
("likely_valid", "可能有效"),
|
||||||
|
("needs_login", "需要登录"),
|
||||||
|
("expired", "已过期"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AUTH_STRATEGY_OPTIONS_JSON = _enum_options(
|
||||||
|
(
|
||||||
|
("password_auto", "账密自动"),
|
||||||
|
("password_with_captcha", "账密+验证码"),
|
||||||
|
("password_plus_2fa", "账密+2FA"),
|
||||||
|
("qr_code_manual", "扫码"),
|
||||||
|
("per_session_manual", "每次手动"),
|
||||||
|
("device_bound", "设备绑定"),
|
||||||
|
("client_certificate", "客户端证书"),
|
||||||
|
("api_token", "API Token"),
|
||||||
|
("sso_redirect", "SSO 跳转"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
YES_NO_OPTIONS_JSON = _enum_options((("1", "是"), ("0", "否")))
|
||||||
|
ENABLED_OPTIONS_JSON = _enum_options((("1", "启用"), ("0", "停用")))
|
||||||
|
DOMAIN_OPTIONS_JSON = _enum_options(
|
||||||
|
(
|
||||||
|
("logistics", "物流"),
|
||||||
|
("llm", "大模型"),
|
||||||
|
("content", "内容"),
|
||||||
|
("dev", "开发"),
|
||||||
|
("sim", "仿真"),
|
||||||
|
("generic", "通用"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
USER_BUSINESS_TABLES = (
|
||||||
|
PLATFORMS_TABLE,
|
||||||
|
ACCOUNTS_TABLE,
|
||||||
|
CREDENTIALS_TABLE,
|
||||||
|
ACCOUNT_LEASES_TABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_JIANGCHANG_TABLES_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS _jiangchang_tables (
|
||||||
|
table_name TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
sort_order INTEGER,
|
||||||
|
visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
readonly INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_JIANGCHANG_COLUMNS_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS _jiangchang_columns (
|
||||||
|
table_name TEXT NOT NULL,
|
||||||
|
column_name TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
display_order INTEGER,
|
||||||
|
visible INTEGER NOT NULL DEFAULT 1,
|
||||||
|
searchable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
editable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
display_type TEXT,
|
||||||
|
options_json TEXT,
|
||||||
|
PRIMARY KEY (table_name, column_name)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _col(
|
||||||
|
column_name: str,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
visible: int = 1,
|
||||||
|
searchable: int = 1,
|
||||||
|
editable: int = 1,
|
||||||
|
display_type: str | None = None,
|
||||||
|
options_json: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"column_name": column_name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"description": description,
|
||||||
|
"visible": visible,
|
||||||
|
"searchable": searchable,
|
||||||
|
"editable": editable,
|
||||||
|
"display_type": display_type,
|
||||||
|
"options_json": options_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(column_name: str, display_name: str, description: str) -> dict[str, object]:
|
||||||
|
return _col(
|
||||||
|
column_name,
|
||||||
|
display_name,
|
||||||
|
description=description,
|
||||||
|
searchable=0,
|
||||||
|
editable=0,
|
||||||
|
display_type=DATETIME_UNIX_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORMS_TABLE_METADATA = {
|
||||||
|
"table_name": PLATFORMS_TABLE,
|
||||||
|
"display_name": "平台信息",
|
||||||
|
"description": "已注册平台(物流、LLM、内容等)及默认登录策略",
|
||||||
|
"sort_order": 1,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
PLATFORMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("platform_key", "平台键", description="平台唯一标识(英文 kebab-case)", editable=0),
|
||||||
|
_col("display_name", "展示名称", description="平台在界面上的显示名称"),
|
||||||
|
_col(
|
||||||
|
"domain",
|
||||||
|
"业务域",
|
||||||
|
description="物流 / 大模型 / 内容等",
|
||||||
|
options_json=DOMAIN_OPTIONS_JSON,
|
||||||
|
),
|
||||||
|
_col("provider_code", "供应商代码", description="供应商或平台代码,可空"),
|
||||||
|
_col("default_url", "默认链接", description="平台默认入口 URL", display_type="url"),
|
||||||
|
_col("aliases_json", "别名列表", description="平台别名 JSON 数组", editable=0),
|
||||||
|
_col("capabilities_json", "能力声明", description='能力 JSON,如 {"web":true,"api_key":false}', editable=0),
|
||||||
|
_col(
|
||||||
|
"enabled",
|
||||||
|
"是否启用",
|
||||||
|
description="启用或停用该平台",
|
||||||
|
options_json=ENABLED_OPTIONS_JSON,
|
||||||
|
),
|
||||||
|
_col(
|
||||||
|
"default_auth_strategy",
|
||||||
|
"默认登录策略",
|
||||||
|
description="新建账号时的默认登录方式",
|
||||||
|
options_json=AUTH_STRATEGY_OPTIONS_JSON,
|
||||||
|
),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNTS_TABLE_METADATA = {
|
||||||
|
"table_name": ACCOUNTS_TABLE,
|
||||||
|
"display_name": "账号信息",
|
||||||
|
"description": "网页/RPA 账号身份与浏览器 Profile 路径",
|
||||||
|
"sort_order": 2,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("id", "编号", description="账号唯一编号", editable=0),
|
||||||
|
_col(
|
||||||
|
"platform_key",
|
||||||
|
"平台",
|
||||||
|
description="所属平台键(来自平台信息表)",
|
||||||
|
display_type="lookup",
|
||||||
|
options_json=PLATFORM_KEY_LOOKUP_JSON,
|
||||||
|
editable=0,
|
||||||
|
),
|
||||||
|
_col("account_label", "账号名称", description="人可读的账号名称"),
|
||||||
|
_col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"),
|
||||||
|
_col(
|
||||||
|
"login_id_type",
|
||||||
|
"登录标识类型",
|
||||||
|
description="未指定 / 手机号 / 邮箱 / 用户名等",
|
||||||
|
options_json=LOGIN_ID_TYPE_OPTIONS_JSON,
|
||||||
|
),
|
||||||
|
_col("tenant_id", "租户编号", description="租户或客户标识,可空", editable=0),
|
||||||
|
_col(
|
||||||
|
"environment",
|
||||||
|
"环境",
|
||||||
|
description="生产 / 仿真 / 预发 / 测试",
|
||||||
|
options_json=ENVIRONMENT_OPTIONS_JSON,
|
||||||
|
editable=0,
|
||||||
|
),
|
||||||
|
_col(
|
||||||
|
"role",
|
||||||
|
"角色",
|
||||||
|
description="默认 / 订舱 / 跟踪 / API 等",
|
||||||
|
options_json=ROLE_OPTIONS_JSON,
|
||||||
|
editable=0,
|
||||||
|
),
|
||||||
|
_col("provider_code", "供应商代码", description="冗余供应商码,便于查询", editable=0),
|
||||||
|
_col("profile_dir", "浏览器配置目录", description="Playwright 持久化 Profile 路径", editable=0),
|
||||||
|
_col("url", "入口链接", description="账号默认入口 URL", display_type="url"),
|
||||||
|
_col(
|
||||||
|
"status",
|
||||||
|
"状态",
|
||||||
|
description="启用 / 停用 / 待复核",
|
||||||
|
options_json=ACCOUNT_STATUS_OPTIONS_JSON,
|
||||||
|
),
|
||||||
|
_col(
|
||||||
|
"session_status",
|
||||||
|
"会话状态",
|
||||||
|
description="运行时登录态,由技能回写",
|
||||||
|
options_json=SESSION_STATUS_OPTIONS_JSON,
|
||||||
|
editable=0,
|
||||||
|
),
|
||||||
|
_ts("last_used_at", "最后使用时间", "最近一次被业务技能使用的时间"),
|
||||||
|
_ts("last_login_check_at", "最后登录检查时间", "最近一次登录状态检查时间"),
|
||||||
|
_col("last_error_code", "最近错误码", description="最近一次错误代码", editable=0),
|
||||||
|
_col("last_error_message", "最近错误信息", description="最近一次错误说明", editable=0),
|
||||||
|
_col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0),
|
||||||
|
_col(
|
||||||
|
"auth_strategy",
|
||||||
|
"登录方式",
|
||||||
|
description="跟随平台默认,一般无需手改",
|
||||||
|
options_json=AUTH_STRATEGY_OPTIONS_JSON,
|
||||||
|
editable=0,
|
||||||
|
),
|
||||||
|
_col(
|
||||||
|
"session_persistent",
|
||||||
|
"持久会话",
|
||||||
|
description="是否需要持久化浏览器会话",
|
||||||
|
options_json=YES_NO_OPTIONS_JSON,
|
||||||
|
editable=0,
|
||||||
|
),
|
||||||
|
_col("device_fingerprint", "设备指纹", description="设备绑定策略专用,可空", editable=0),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
CREDENTIALS_TABLE_METADATA = {
|
||||||
|
"table_name": CREDENTIALS_TABLE,
|
||||||
|
"display_name": "凭证信息",
|
||||||
|
"description": "API Key、Token、加密密码等凭据元数据(不含明文)",
|
||||||
|
"sort_order": 3,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("id", "编号", description="凭据唯一编号", editable=0),
|
||||||
|
_col(
|
||||||
|
"account_id",
|
||||||
|
"账号编号",
|
||||||
|
description="关联的 accounts.id,可空",
|
||||||
|
display_type="lookup",
|
||||||
|
options_json=ACCOUNT_ID_LOOKUP_JSON,
|
||||||
|
),
|
||||||
|
_col(
|
||||||
|
"platform_key",
|
||||||
|
"平台",
|
||||||
|
description="所属平台键(来自平台信息表)",
|
||||||
|
display_type="lookup",
|
||||||
|
options_json=PLATFORM_KEY_LOOKUP_JSON,
|
||||||
|
),
|
||||||
|
_col("credential_label", "凭据名称", description="人可读的凭据名称"),
|
||||||
|
_col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"),
|
||||||
|
_col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0),
|
||||||
|
_col("secret_ref", "密钥引用", description="环境变量名或凭据管理器 target", editable=0, searchable=0),
|
||||||
|
_col("secret_mask", "密钥掩码", description="打码后的 secret 展示", editable=0, searchable=0),
|
||||||
|
_col(
|
||||||
|
"secret_ciphertext",
|
||||||
|
"密钥密文",
|
||||||
|
description="local_encrypted 时的 Fernet 密文,不可手工编辑",
|
||||||
|
editable=0,
|
||||||
|
searchable=0,
|
||||||
|
),
|
||||||
|
_ts("expires_at", "过期时间", "凭据过期时间,可空"),
|
||||||
|
_col("status", "状态", description="active / disabled / expired 等"),
|
||||||
|
_ts("last_used_at", "最后使用时间", "最近一次使用时间"),
|
||||||
|
_col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCOUNT_LEASES_TABLE_METADATA = {
|
||||||
|
"table_name": ACCOUNT_LEASES_TABLE,
|
||||||
|
"display_name": "账号租约",
|
||||||
|
"description": "RPA 并发租约,防止多任务同时使用同一浏览器 Profile",
|
||||||
|
"sort_order": 4,
|
||||||
|
"visible": 1,
|
||||||
|
"readonly": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCOUNT_LEASES_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||||
|
_col("id", "编号", description="租约唯一编号", editable=0),
|
||||||
|
_col("account_id", "账号编号", description="关联的 accounts.id", editable=0),
|
||||||
|
_col("lease_token", "租约令牌", description="用于 release 的随机 token", editable=0, searchable=1),
|
||||||
|
_col("holder", "持有者", description="持有租约的业务技能或任务标识", editable=0),
|
||||||
|
_col("purpose", "用途", description="租约用途说明", editable=0),
|
||||||
|
_ts("expires_at", "过期时间", "租约过期时间"),
|
||||||
|
_ts("heartbeat_at", "心跳时间", "最近一次心跳时间"),
|
||||||
|
_ts("released_at", "释放时间", "主动释放时间,可空"),
|
||||||
|
_col("status", "状态", description="active / released / expired", editable=0),
|
||||||
|
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
|
||||||
|
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_TABLE_SEEDS: tuple[tuple[dict[str, object], tuple[dict[str, object], ...]], ...] = (
|
||||||
|
(PLATFORMS_TABLE_METADATA, PLATFORMS_COLUMN_METADATA),
|
||||||
|
(ACCOUNTS_TABLE_METADATA, ACCOUNTS_COLUMN_METADATA),
|
||||||
|
(CREDENTIALS_TABLE_METADATA, CREDENTIALS_COLUMN_METADATA),
|
||||||
|
(ACCOUNT_LEASES_TABLE_METADATA, ACCOUNT_LEASES_COLUMN_METADATA),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_display_metadata_tables(cursor) -> None:
|
||||||
|
cursor.execute(_JIANGCHANG_TABLES_DDL)
|
||||||
|
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_table_metadata(
|
||||||
|
cursor,
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
sort_order: int | None = None,
|
||||||
|
visible: int = 1,
|
||||||
|
readonly: int = 0,
|
||||||
|
) -> None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO _jiangchang_tables (
|
||||||
|
table_name, display_name, description, sort_order, visible, readonly
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(table_name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
sort_order = excluded.sort_order,
|
||||||
|
visible = excluded.visible,
|
||||||
|
readonly = excluded.readonly
|
||||||
|
""",
|
||||||
|
(table_name, display_name, description, sort_order, visible, readonly),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_column_metadata(
|
||||||
|
cursor,
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
column_name: str,
|
||||||
|
display_name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
visible: int = 1,
|
||||||
|
searchable: int = 1,
|
||||||
|
editable: int = 1,
|
||||||
|
display_type: str | None = None,
|
||||||
|
options_json: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO _jiangchang_columns (
|
||||||
|
table_name, column_name, display_name, description,
|
||||||
|
display_order, visible, searchable, editable, display_type, options_json
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(table_name, column_name) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
description = excluded.description,
|
||||||
|
display_order = NULL,
|
||||||
|
visible = excluded.visible,
|
||||||
|
searchable = excluded.searchable,
|
||||||
|
editable = excluded.editable,
|
||||||
|
display_type = excluded.display_type,
|
||||||
|
options_json = excluded.options_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
display_name,
|
||||||
|
description,
|
||||||
|
visible,
|
||||||
|
searchable,
|
||||||
|
editable,
|
||||||
|
display_type,
|
||||||
|
options_json,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_column_metadata_batch(
|
||||||
|
cursor,
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
columns: Iterable[dict[str, object]],
|
||||||
|
valid_column_names: Iterable[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
for column in columns:
|
||||||
|
upsert_column_metadata(
|
||||||
|
cursor,
|
||||||
|
table_name=table_name,
|
||||||
|
column_name=str(column["column_name"]),
|
||||||
|
display_name=str(column["display_name"]),
|
||||||
|
description=str(column["description"]) if column.get("description") else None,
|
||||||
|
visible=int(column.get("visible", 1)),
|
||||||
|
searchable=int(column.get("searchable", 1)),
|
||||||
|
editable=int(column.get("editable", 1)),
|
||||||
|
display_type=str(column["display_type"]) if column.get("display_type") else None,
|
||||||
|
options_json=str(column["options_json"]) if column.get("options_json") else None,
|
||||||
|
)
|
||||||
|
if valid_column_names is not None:
|
||||||
|
prune_stale_column_metadata(cursor, table_name, valid_column_names)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None:
|
||||||
|
valid = set(valid_column_names)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
for (column_name,) in cursor.fetchall():
|
||||||
|
if str(column_name) not in valid:
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?",
|
||||||
|
(table_name, column_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_table_readonly(cursor, table_name: str) -> int | None:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return int(row[0] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_column_editable(cursor, table_name: str, column_name: str) -> int | None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
""",
|
||||||
|
(table_name, column_name),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return int(row[0] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def list_editable_business_columns(cursor, table_name: str) -> list[str]:
|
||||||
|
physical = list_physical_column_names(cursor, table_name)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, editable
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ?
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
editable_set = {
|
||||||
|
str(column_name)
|
||||||
|
for column_name, editable in cursor.fetchall()
|
||||||
|
if editable is not None and int(editable) == 1
|
||||||
|
}
|
||||||
|
return [name for name in physical if name in editable_set]
|
||||||
|
|
||||||
|
|
||||||
|
def table_allows_manual_create(cursor, table_name: str) -> bool:
|
||||||
|
readonly = get_table_readonly(cursor, table_name)
|
||||||
|
if readonly is None or int(readonly) != 0:
|
||||||
|
return False
|
||||||
|
return bool(list_editable_business_columns(cursor, table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def seed_business_table_display_metadata(cursor, table_meta: dict[str, object], columns: tuple[dict[str, object], ...]) -> None:
|
||||||
|
table_name = str(table_meta["table_name"])
|
||||||
|
upsert_table_metadata(
|
||||||
|
cursor,
|
||||||
|
table_name=table_name,
|
||||||
|
display_name=str(table_meta["display_name"]),
|
||||||
|
description=str(table_meta["description"]) if table_meta.get("description") else None,
|
||||||
|
sort_order=int(table_meta["sort_order"]) if table_meta.get("sort_order") is not None else None,
|
||||||
|
visible=int(table_meta.get("visible", 1)),
|
||||||
|
readonly=int(table_meta.get("readonly", 0)),
|
||||||
|
)
|
||||||
|
seed_column_metadata_batch(
|
||||||
|
cursor,
|
||||||
|
table_name=table_name,
|
||||||
|
columns=columns,
|
||||||
|
valid_column_names=[str(column["column_name"]) for column in columns],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_display_metadata(cursor) -> None:
|
||||||
|
init_display_metadata_tables(cursor)
|
||||||
|
for table_meta, columns in _TABLE_SEEDS:
|
||||||
|
seed_business_table_display_metadata(cursor, table_meta, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_user_tables(cursor) -> Iterable[str]:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||||
|
)
|
||||||
|
for (name,) in cursor.fetchall():
|
||||||
|
if name in METADATA_TABLES:
|
||||||
|
continue
|
||||||
|
yield str(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_identifier(name: str) -> str:
|
||||||
|
return '"' + name.replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def list_physical_column_names(cursor, table_name: str) -> list[str]:
|
||||||
|
cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
rows.sort(key=lambda row: int(row[0]))
|
||||||
|
return [str(row[1]) for row in rows]
|
||||||
@@ -36,12 +36,14 @@ from service.secret_store import (
|
|||||||
make_windows_credential_target,
|
make_windows_credential_target,
|
||||||
write_secret,
|
write_secret,
|
||||||
)
|
)
|
||||||
|
from util.account_identity import resolve_login_id_type
|
||||||
from util.logging_config import get_skill_logger
|
from util.logging_config import get_skill_logger
|
||||||
from util.platforms import get_platform_spec, seed_platforms
|
from util.platforms import get_platform_spec, seed_platforms
|
||||||
|
from util.profile_shortcut import create_profile_browser_shortcut
|
||||||
from util.runtime_paths import get_default_profile_dir_for_web
|
from util.runtime_paths import get_default_profile_dir_for_web
|
||||||
|
|
||||||
|
|
||||||
def cmd_account_add_web(
|
def create_web_account_record(
|
||||||
platform_input: str,
|
platform_input: str,
|
||||||
login_id: Optional[str],
|
login_id: Optional[str],
|
||||||
login_id_type: str,
|
login_id_type: str,
|
||||||
@@ -58,15 +60,17 @@ def cmd_account_add_web(
|
|||||||
session_persistent: Optional[int] = None,
|
session_persistent: Optional[int] = None,
|
||||||
device_fingerprint: Optional[str] = None,
|
device_fingerprint: Optional[str] = None,
|
||||||
platform_meta: Optional[PlatformCallerMeta] = None,
|
platform_meta: Optional[PlatformCallerMeta] = None,
|
||||||
) -> None:
|
platform_key: Optional[str] = None,
|
||||||
"""account add-web — create a web/RPA account record."""
|
) -> Optional[dict]:
|
||||||
|
"""Create a web/RPA account. On error prints JSON and returns None; on success returns payload (no stdout)."""
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
|
login_id_type = resolve_login_id_type(login_id_type, login_id)
|
||||||
log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s",
|
log.info("account_add_web_attempt platform=%s login_id_type=%s env=%s role=%s",
|
||||||
platform_input, login_id_type, environment, role)
|
platform_input, login_id_type, environment, role)
|
||||||
|
|
||||||
key = _resolve_or_auto_register(platform_input, platform_meta)
|
key = platform_key or _resolve_or_auto_register(platform_input, platform_meta)
|
||||||
if not key:
|
if not key:
|
||||||
return
|
return None
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
@@ -84,7 +88,7 @@ def cmd_account_add_web(
|
|||||||
extra = json.loads(extra_json_str)
|
extra = json.loads(extra_json_str)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
_say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。"))
|
_say(_err_json("ERROR:CLI_BAD_ARGS", "extra_json 不是合法的 JSON 字符串。"))
|
||||||
return
|
return None
|
||||||
|
|
||||||
if profile_dir_override:
|
if profile_dir_override:
|
||||||
resolved_profile_dir = os.path.abspath(profile_dir_override)
|
resolved_profile_dir = os.path.abspath(profile_dir_override)
|
||||||
@@ -112,7 +116,7 @@ def cmd_account_add_web(
|
|||||||
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
"ERR_AUTH_STRATEGY_NOT_SUPPORTED",
|
||||||
f"不支持的 auth_strategy:{auth_strategy}",
|
f"不支持的 auth_strategy:{auth_strategy}",
|
||||||
))
|
))
|
||||||
return
|
return None
|
||||||
resolved_auth = auth_strategy
|
resolved_auth = auth_strategy
|
||||||
|
|
||||||
if session_persistent is None:
|
if session_persistent is None:
|
||||||
@@ -123,7 +127,7 @@ def cmd_account_add_web(
|
|||||||
"ERR_INVALID_SESSION_PERSISTENT",
|
"ERR_INVALID_SESSION_PERSISTENT",
|
||||||
"session_persistent 必须是 0 或 1。",
|
"session_persistent 必须是 0 或 1。",
|
||||||
))
|
))
|
||||||
return
|
return None
|
||||||
sp = session_persistent
|
sp = session_persistent
|
||||||
|
|
||||||
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
|
if device_fingerprint is not None and resolved_auth != AUTH_STRATEGY_DEVICE_BOUND:
|
||||||
@@ -131,7 +135,7 @@ def cmd_account_add_web(
|
|||||||
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
|
"ERR_DEVICE_FINGERPRINT_NOT_APPLICABLE",
|
||||||
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
|
"device_fingerprint 仅在 auth_strategy=device_bound 时可设置。",
|
||||||
))
|
))
|
||||||
return
|
return None
|
||||||
|
|
||||||
new_id = insert_account(
|
new_id = insert_account(
|
||||||
conn=conn,
|
conn=conn,
|
||||||
@@ -156,12 +160,16 @@ def cmd_account_add_web(
|
|||||||
conn.rollback()
|
conn.rollback()
|
||||||
log.exception("account_add_web_failure")
|
log.exception("account_add_web_failure")
|
||||||
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
_say(_err_json("ERROR:DB_ERROR", "Database insert failed."))
|
||||||
return
|
return None
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
shortcut_path = create_profile_browser_shortcut(
|
||||||
|
resolved_profile_dir, safe_label, log, url=resolved_url,
|
||||||
|
)
|
||||||
|
|
||||||
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
|
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
|
||||||
_say(_ok_json({
|
ok_payload = {
|
||||||
"id": new_id,
|
"id": new_id,
|
||||||
"platform_key": key,
|
"platform_key": key,
|
||||||
"account_label": safe_label,
|
"account_label": safe_label,
|
||||||
@@ -176,7 +184,50 @@ def cmd_account_add_web(
|
|||||||
"auth_strategy": resolved_auth,
|
"auth_strategy": resolved_auth,
|
||||||
"session_persistent": sp,
|
"session_persistent": sp,
|
||||||
"device_fingerprint": device_fingerprint,
|
"device_fingerprint": device_fingerprint,
|
||||||
}))
|
}
|
||||||
|
if shortcut_path:
|
||||||
|
ok_payload["profile_shortcut"] = shortcut_path
|
||||||
|
return ok_payload
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_add_web(
|
||||||
|
platform_input: str,
|
||||||
|
login_id: Optional[str],
|
||||||
|
login_id_type: str,
|
||||||
|
label: Optional[str],
|
||||||
|
tenant_id: Optional[str],
|
||||||
|
environment: str,
|
||||||
|
role: str,
|
||||||
|
provider_code: Optional[str],
|
||||||
|
url: Optional[str],
|
||||||
|
extra_json_str: Optional[str],
|
||||||
|
profile_dir_override: Optional[str],
|
||||||
|
*,
|
||||||
|
auth_strategy: Optional[str] = None,
|
||||||
|
session_persistent: Optional[int] = None,
|
||||||
|
device_fingerprint: Optional[str] = None,
|
||||||
|
platform_meta: Optional[PlatformCallerMeta] = None,
|
||||||
|
) -> None:
|
||||||
|
"""account add-web — create a web/RPA account record."""
|
||||||
|
ok_payload = create_web_account_record(
|
||||||
|
platform_input,
|
||||||
|
login_id,
|
||||||
|
login_id_type,
|
||||||
|
label,
|
||||||
|
tenant_id,
|
||||||
|
environment,
|
||||||
|
role,
|
||||||
|
provider_code,
|
||||||
|
url,
|
||||||
|
extra_json_str,
|
||||||
|
profile_dir_override,
|
||||||
|
auth_strategy=auth_strategy,
|
||||||
|
session_persistent=session_persistent,
|
||||||
|
device_fingerprint=device_fingerprint,
|
||||||
|
platform_meta=platform_meta,
|
||||||
|
)
|
||||||
|
if ok_payload:
|
||||||
|
_say(_ok_json(ok_payload))
|
||||||
|
|
||||||
|
|
||||||
def cmd_account_add_secret(
|
def cmd_account_add_secret(
|
||||||
|
|||||||
174
scripts/service/account_ensure_commands.py
Normal file
174
scripts/service/account_ensure_commands.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""account ensure-web — pick if present, otherwise add-web then pick."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from db.accounts_repo import count_active_accounts
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
from service._svc_common import PlatformCallerMeta, _err_json, _resolve_or_auto_register, _say
|
||||||
|
from service.account_add_commands import create_web_account_record
|
||||||
|
from service.account_query_commands import cmd_account_pick_web
|
||||||
|
from util.logging_config import get_skill_logger
|
||||||
|
from util.platforms import seed_platforms
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_ensure_web(
|
||||||
|
platform_input: str,
|
||||||
|
*,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
login_id_type: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
tenant_id: Optional[str] = None,
|
||||||
|
environment: Optional[str] = None,
|
||||||
|
role: Optional[str] = None,
|
||||||
|
provider_code: Optional[str] = None,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
extra_json_str: Optional[str] = None,
|
||||||
|
profile_dir_override: Optional[str] = None,
|
||||||
|
auth_strategy: Optional[str] = None,
|
||||||
|
session_persistent: Optional[int] = None,
|
||||||
|
device_fingerprint: Optional[str] = None,
|
||||||
|
do_lease: bool = False,
|
||||||
|
ttl_sec: int = 900,
|
||||||
|
holder: Optional[str] = None,
|
||||||
|
purpose: Optional[str] = None,
|
||||||
|
platform_meta: Optional[PlatformCallerMeta] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Pick a web account; if none exist for filters, create one then pick.
|
||||||
|
|
||||||
|
Does NOT create a new account when matching accounts exist but are all leased.
|
||||||
|
Success stdout matches ``account pick-web`` (plus ``account_ensured`` when created).
|
||||||
|
"""
|
||||||
|
log = get_skill_logger()
|
||||||
|
log.info(
|
||||||
|
"account_ensure_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s",
|
||||||
|
platform_input,
|
||||||
|
environment,
|
||||||
|
tenant_id,
|
||||||
|
role,
|
||||||
|
do_lease,
|
||||||
|
)
|
||||||
|
|
||||||
|
key = _resolve_or_auto_register(platform_input, platform_meta)
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
seed_platforms(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
lid = (login_id or "").strip() or None
|
||||||
|
lab = (label or "").strip() or None
|
||||||
|
|
||||||
|
existing_count = count_active_accounts(
|
||||||
|
platform_key=key,
|
||||||
|
environment=environment,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
role=role,
|
||||||
|
login_id=lid,
|
||||||
|
label=lab,
|
||||||
|
)
|
||||||
|
created = False
|
||||||
|
if existing_count == 0:
|
||||||
|
create_env = (environment or "").strip() or "production"
|
||||||
|
create_role = (role or "").strip() or "default"
|
||||||
|
create_label = lab or None
|
||||||
|
if not create_label:
|
||||||
|
create_label = f"{key} 默认"
|
||||||
|
if lid:
|
||||||
|
create_label = f"{key} {lid}"
|
||||||
|
|
||||||
|
created_payload = create_web_account_record(
|
||||||
|
platform_input,
|
||||||
|
lid,
|
||||||
|
login_id_type,
|
||||||
|
create_label,
|
||||||
|
tenant_id,
|
||||||
|
create_env,
|
||||||
|
create_role,
|
||||||
|
provider_code,
|
||||||
|
url,
|
||||||
|
extra_json_str,
|
||||||
|
profile_dir_override,
|
||||||
|
auth_strategy=auth_strategy,
|
||||||
|
session_persistent=session_persistent,
|
||||||
|
device_fingerprint=device_fingerprint,
|
||||||
|
platform_meta=platform_meta,
|
||||||
|
platform_key=key,
|
||||||
|
)
|
||||||
|
if not created_payload:
|
||||||
|
return
|
||||||
|
created = True
|
||||||
|
log.info(
|
||||||
|
"account_ensure_web_created id=%s platform=%s login_id=%s profile_dir=%s",
|
||||||
|
created_payload.get("id"),
|
||||||
|
key,
|
||||||
|
lid,
|
||||||
|
created_payload.get("profile_dir"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info(
|
||||||
|
"account_ensure_web_reuse platform=%s existing_active=%s login_id=%s label=%s",
|
||||||
|
key,
|
||||||
|
existing_count,
|
||||||
|
lid,
|
||||||
|
lab,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture pick output so we can annotate / remap lease-busy errors.
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
old_out = sys.stdout
|
||||||
|
sys.stdout = buf
|
||||||
|
try:
|
||||||
|
cmd_account_pick_web(
|
||||||
|
platform_input,
|
||||||
|
environment=environment,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
role=role,
|
||||||
|
login_id=lid,
|
||||||
|
label=lab,
|
||||||
|
do_lease=do_lease,
|
||||||
|
ttl_sec=ttl_sec,
|
||||||
|
holder=holder,
|
||||||
|
purpose=purpose,
|
||||||
|
platform_meta=platform_meta,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
sys.stdout = old_out
|
||||||
|
|
||||||
|
raw = buf.getvalue().strip()
|
||||||
|
if not raw:
|
||||||
|
_say(_err_json("ERROR:NO_ACCOUNT", "ensure 后未能挑选到账号。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
_say(raw)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(payload, dict) and payload.get("success") is False:
|
||||||
|
err = (payload.get("error") or {}) if isinstance(payload.get("error"), dict) else {}
|
||||||
|
code = str(err.get("code") or "")
|
||||||
|
if code == "ERROR:NO_ACCOUNT" and existing_count > 0 and not created:
|
||||||
|
scope = "该筛选条件下" if (lid or lab) else "该平台下"
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:LEASE_CONFLICT",
|
||||||
|
f"{scope}已有账号,但均被其他任务占用。请稍后重试或释放租约;不会自动新建账号。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
_say(raw)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(payload, dict) and created:
|
||||||
|
payload["account_ensured"] = True
|
||||||
|
_say(json.dumps(payload, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
_say(raw)
|
||||||
233
scripts/service/account_mutate_commands.py
Normal file
233
scripts/service/account_mutate_commands.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Account copy / update CLI commands (data-management 复制 / 编辑)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from db.accounts_repo import (
|
||||||
|
_UNSET,
|
||||||
|
get_account_by_id,
|
||||||
|
list_account_labels,
|
||||||
|
update_account_user_fields,
|
||||||
|
)
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
from service._svc_common import _err_json, _say
|
||||||
|
from service.account_add_commands import create_web_account_record
|
||||||
|
from util.account_identity import (
|
||||||
|
KNOWN_ACCOUNT_STATUSES,
|
||||||
|
next_copy_label,
|
||||||
|
resolve_login_id_type,
|
||||||
|
)
|
||||||
|
from util.logging_config import get_skill_logger
|
||||||
|
from util.platforms import get_platform_spec, seed_platforms
|
||||||
|
from util.profile_shortcut import create_profile_browser_shortcut
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_json_str(account: dict) -> Optional[str]:
|
||||||
|
extra = account.get("extra_json")
|
||||||
|
if extra is None or extra == "":
|
||||||
|
return None
|
||||||
|
if isinstance(extra, (dict, list)):
|
||||||
|
return json.dumps(extra, ensure_ascii=False)
|
||||||
|
text = str(extra).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_result(data: dict, message: str) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"type": "result",
|
||||||
|
"schema_version": 1,
|
||||||
|
"status": "success",
|
||||||
|
"message": message,
|
||||||
|
"success": True,
|
||||||
|
"data": data,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_copy(account_id) -> None:
|
||||||
|
"""Clone a web account: same platform/strategy, new empty Profile, empty login_id."""
|
||||||
|
log = get_skill_logger()
|
||||||
|
log.info("account_copy_attempt id=%r", account_id)
|
||||||
|
try:
|
||||||
|
aid = int(account_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
_say(_err_json("ERROR:CLI_BAD_ARGS", "account copy 需要整数 id。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
source = get_account_by_id(aid)
|
||||||
|
if not source:
|
||||||
|
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
labels = list_account_labels(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
new_label = next_copy_label(str(source.get("account_label") or ""), labels)
|
||||||
|
tenant = str(source.get("tenant_id") or "").strip() or None
|
||||||
|
provider = str(source.get("provider_code") or "").strip() or None
|
||||||
|
url = str(source.get("url") or "").strip() or None
|
||||||
|
extra = _extra_json_str(source)
|
||||||
|
session_persistent = source.get("session_persistent")
|
||||||
|
try:
|
||||||
|
session_persistent_i = int(session_persistent) if session_persistent is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
session_persistent_i = None
|
||||||
|
|
||||||
|
ok_payload = create_web_account_record(
|
||||||
|
str(source.get("platform_key") or ""),
|
||||||
|
None,
|
||||||
|
str(source.get("login_id_type") or "unknown"),
|
||||||
|
new_label,
|
||||||
|
tenant,
|
||||||
|
str(source.get("environment") or "production") or "production",
|
||||||
|
str(source.get("role") or "default") or "default",
|
||||||
|
provider,
|
||||||
|
url,
|
||||||
|
extra,
|
||||||
|
None,
|
||||||
|
auth_strategy=source.get("auth_strategy"),
|
||||||
|
session_persistent=session_persistent_i,
|
||||||
|
device_fingerprint=None,
|
||||||
|
platform_key=str(source.get("platform_key") or "") or None,
|
||||||
|
)
|
||||||
|
if not ok_payload:
|
||||||
|
return
|
||||||
|
|
||||||
|
ok_payload["copied_from_id"] = aid
|
||||||
|
log.info(
|
||||||
|
"account_copy_success from=%s to=%s label=%s",
|
||||||
|
aid,
|
||||||
|
ok_payload.get("id"),
|
||||||
|
new_label,
|
||||||
|
)
|
||||||
|
_say(_ok_result(ok_payload, f"已复制为「{new_label}」"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_update(
|
||||||
|
account_id,
|
||||||
|
*,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
login_id: Any = _UNSET,
|
||||||
|
login_id_type: Optional[str] = None,
|
||||||
|
url: Any = _UNSET,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update the short set of user-facing account fields."""
|
||||||
|
log = get_skill_logger()
|
||||||
|
log.info("account_update_attempt id=%r", account_id)
|
||||||
|
try:
|
||||||
|
aid = int(account_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
_say(_err_json("ERROR:CLI_BAD_ARGS", "account update 需要整数 --id。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
current = get_account_by_id(aid)
|
||||||
|
if not current:
|
||||||
|
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
next_label = None
|
||||||
|
if label is not None:
|
||||||
|
next_label = str(label).strip()
|
||||||
|
if not next_label:
|
||||||
|
_say(_err_json("ERROR:CLI_BAD_ARGS", "账号名称不能为空。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
next_status = None
|
||||||
|
if status is not None:
|
||||||
|
next_status = str(status).strip()
|
||||||
|
if next_status not in KNOWN_ACCOUNT_STATUSES:
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:CLI_BAD_ARGS",
|
||||||
|
f"不支持的状态:{status}。支持:active / disabled / needs_review。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
resolved_login_id = _UNSET
|
||||||
|
if login_id is not _UNSET:
|
||||||
|
resolved_login_id = str(login_id).strip() if login_id is not None else ""
|
||||||
|
|
||||||
|
next_type = None
|
||||||
|
if login_id_type is not None or resolved_login_id is not _UNSET:
|
||||||
|
lid_for_infer = (
|
||||||
|
resolved_login_id
|
||||||
|
if resolved_login_id is not _UNSET
|
||||||
|
else current.get("login_id")
|
||||||
|
)
|
||||||
|
explicit = login_id_type if login_id_type is not None else current.get("login_id_type")
|
||||||
|
next_type = resolve_login_id_type(str(explicit or ""), lid_for_infer)
|
||||||
|
|
||||||
|
resolved_url = _UNSET
|
||||||
|
if url is not _UNSET:
|
||||||
|
text = str(url).strip() if url is not None else ""
|
||||||
|
if text:
|
||||||
|
resolved_url = text
|
||||||
|
else:
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
seed_platforms(conn)
|
||||||
|
spec = get_platform_spec(str(current.get("platform_key") or ""), conn=conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
resolved_url = str(spec.get("default_url") or "")
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
update_account_user_fields(
|
||||||
|
conn,
|
||||||
|
aid,
|
||||||
|
account_label=next_label,
|
||||||
|
login_id=resolved_login_id,
|
||||||
|
login_id_type=next_type,
|
||||||
|
url=resolved_url,
|
||||||
|
status=next_status,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
conn.rollback()
|
||||||
|
_say(_err_json("ERROR:CLI_BAD_ARGS", str(exc)))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
log.exception("account_update_failure")
|
||||||
|
_say(_err_json("ERROR:DB_ERROR", "Database update failed."))
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
updated = get_account_by_id(aid)
|
||||||
|
if not updated:
|
||||||
|
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
|
||||||
|
return
|
||||||
|
|
||||||
|
profile_dir = str(updated.get("profile_dir") or "").strip()
|
||||||
|
if profile_dir:
|
||||||
|
create_profile_browser_shortcut(
|
||||||
|
profile_dir,
|
||||||
|
str(updated.get("account_label") or ""),
|
||||||
|
log,
|
||||||
|
url=str(updated.get("url") or "") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info("account_update_success id=%s", aid)
|
||||||
|
_say(_ok_result(
|
||||||
|
{
|
||||||
|
"id": updated["id"],
|
||||||
|
"platform_key": updated.get("platform_key"),
|
||||||
|
"account_label": updated.get("account_label"),
|
||||||
|
"login_id": updated.get("login_id") or "",
|
||||||
|
"login_id_type": updated.get("login_id_type"),
|
||||||
|
"url": updated.get("url") or "",
|
||||||
|
"status": updated.get("status"),
|
||||||
|
},
|
||||||
|
"已保存",
|
||||||
|
))
|
||||||
@@ -193,12 +193,21 @@ def cmd_account_list(
|
|||||||
environment: Optional[str] = None,
|
environment: Optional[str] = None,
|
||||||
tenant_id: Optional[str] = None,
|
tenant_id: Optional[str] = None,
|
||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""account list — output JSON array of accounts matching filters."""
|
"""account list — output JSON array of accounts matching filters."""
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
log.info("cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s",
|
log.info(
|
||||||
platform_input, environment, tenant_id, role)
|
"cli_start subcommand=account_list platform=%s env=%s tenant=%s role=%s login_id=%s label=%s",
|
||||||
|
platform_input,
|
||||||
|
environment,
|
||||||
|
tenant_id,
|
||||||
|
role,
|
||||||
|
login_id,
|
||||||
|
label,
|
||||||
|
)
|
||||||
init_db()
|
init_db()
|
||||||
key = None
|
key = None
|
||||||
if platform_input and platform_input not in ("all", "全部", ""):
|
if platform_input and platform_input not in ("all", "全部", ""):
|
||||||
@@ -211,11 +220,115 @@ def cmd_account_list(
|
|||||||
environment=environment,
|
environment=environment,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
role=role,
|
role=role,
|
||||||
|
login_id=login_id,
|
||||||
|
label=label,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
_say(json.dumps(accounts, ensure_ascii=False))
|
_say(json.dumps(accounts, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_account_resolve(
|
||||||
|
*,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
platform_input: Optional[str] = None,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Resolve one account by id, login_id, label, or free-form query.
|
||||||
|
|
||||||
|
Success: same JSON shape as ``account get``.
|
||||||
|
Errors: ACCOUNT_NOT_FOUND / NO_ACCOUNT / AMBIGUOUS_ACCOUNT / CLI_BAD_ARGS.
|
||||||
|
"""
|
||||||
|
log = get_skill_logger()
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
aid_raw = (account_id or "").strip()
|
||||||
|
q = (query or "").strip()
|
||||||
|
lid = (login_id or "").strip()
|
||||||
|
lab = (label or "").strip()
|
||||||
|
|
||||||
|
# Free-form: digits → id; otherwise treat as login_id (then label fallback).
|
||||||
|
if q and not aid_raw and not lid and not lab:
|
||||||
|
if q.isdigit():
|
||||||
|
aid_raw = q
|
||||||
|
else:
|
||||||
|
lid = q
|
||||||
|
|
||||||
|
if aid_raw:
|
||||||
|
try:
|
||||||
|
aid = int(aid_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
_say(_err_json("ERROR:CLI_BAD_ARGS", f"账号 id 必须是整数:{aid_raw}"))
|
||||||
|
return
|
||||||
|
acc = get_account_by_id(aid)
|
||||||
|
if not acc:
|
||||||
|
_say(_err_json("ERROR:ACCOUNT_NOT_FOUND", f"账号 id={aid} 不存在。"))
|
||||||
|
return
|
||||||
|
log.info("account_resolve_by_id id=%s", aid)
|
||||||
|
_say(json.dumps(acc, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not lid and not lab:
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:CLI_BAD_ARGS",
|
||||||
|
"account resolve 需要 --id、<数字 query>、--login-id 或 --label(也可用 --query 用户名/手机号)。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
key = None
|
||||||
|
if platform_input and platform_input not in ("all", "全部", ""):
|
||||||
|
key = _resolve_or_fail(platform_input)
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
elif not platform_input:
|
||||||
|
# login_id/label without platform: search all platforms
|
||||||
|
key = None
|
||||||
|
|
||||||
|
matches = find_accounts(
|
||||||
|
platform_key=key,
|
||||||
|
login_id=lid or None,
|
||||||
|
label=lab or None,
|
||||||
|
status="active",
|
||||||
|
limit=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If query was used as login_id and miss, retry as label.
|
||||||
|
if not matches and q and not login_id and not label and not q.isdigit():
|
||||||
|
matches = find_accounts(
|
||||||
|
platform_key=key,
|
||||||
|
label=q,
|
||||||
|
status="active",
|
||||||
|
limit=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
ref = lid or lab or q
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:NO_ACCOUNT",
|
||||||
|
f"未找到匹配的账号(ref={ref!r}"
|
||||||
|
+ (f", platform={key}" if key else "")
|
||||||
|
+ ")。",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
if len(matches) > 1:
|
||||||
|
ids = [m.get("id") for m in matches[:10]]
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:AMBIGUOUS_ACCOUNT",
|
||||||
|
f"匹配到多条账号,请改用 --id 或补充 --platform。候选 id={ids}",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
acc = matches[0]
|
||||||
|
log.info(
|
||||||
|
"account_resolve_ok id=%s login_id=%s label=%s",
|
||||||
|
acc.get("id"),
|
||||||
|
acc.get("login_id"),
|
||||||
|
acc.get("account_label"),
|
||||||
|
)
|
||||||
|
_say(json.dumps(acc, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
|
def cmd_list_json(platform_input: str, limit: int = 200) -> None:
|
||||||
"""Legacy: list-json <platform|all> [--limit N]"""
|
"""Legacy: list-json <platform|all> [--limit N]"""
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
@@ -238,6 +351,8 @@ def cmd_account_pick_web(
|
|||||||
environment: Optional[str] = None,
|
environment: Optional[str] = None,
|
||||||
tenant_id: Optional[str] = None,
|
tenant_id: Optional[str] = None,
|
||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
|
login_id: Optional[str] = None,
|
||||||
|
label: Optional[str] = None,
|
||||||
do_lease: bool = False,
|
do_lease: bool = False,
|
||||||
ttl_sec: int = 900,
|
ttl_sec: int = 900,
|
||||||
holder: Optional[str] = None,
|
holder: Optional[str] = None,
|
||||||
@@ -246,8 +361,16 @@ def cmd_account_pick_web(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Pick a web/RPA account for use. Optionally acquire a lease."""
|
"""Pick a web/RPA account for use. Optionally acquire a lease."""
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
log.info("account_pick_web_attempt platform=%s env=%s tenant=%s role=%s lease=%s",
|
log.info(
|
||||||
platform_input, environment, tenant_id, role, do_lease)
|
"account_pick_web_attempt platform=%s env=%s tenant=%s role=%s login_id=%s label=%s lease=%s",
|
||||||
|
platform_input,
|
||||||
|
environment,
|
||||||
|
tenant_id,
|
||||||
|
role,
|
||||||
|
login_id,
|
||||||
|
label,
|
||||||
|
do_lease,
|
||||||
|
)
|
||||||
|
|
||||||
key = _resolve_or_auto_register(platform_input, platform_meta)
|
key = _resolve_or_auto_register(platform_input, platform_meta)
|
||||||
if not key:
|
if not key:
|
||||||
@@ -260,15 +383,45 @@ def cmd_account_pick_web(
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
lid = (login_id or "").strip() or None
|
||||||
|
lab = (label or "").strip() or None
|
||||||
|
|
||||||
|
# Human-ref filters should be unique; ambiguous active matches → explicit error.
|
||||||
|
if lid or lab:
|
||||||
|
active_matches = find_accounts(
|
||||||
|
platform_key=key,
|
||||||
|
environment=environment,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
role=role,
|
||||||
|
status="active",
|
||||||
|
login_id=lid,
|
||||||
|
label=lab,
|
||||||
|
limit=20,
|
||||||
|
)
|
||||||
|
if len(active_matches) > 1:
|
||||||
|
ids = [m.get("id") for m in active_matches[:10]]
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:AMBIGUOUS_ACCOUNT",
|
||||||
|
f"login_id/label 匹配到多条账号,请改用 account get <id>。候选 id={ids}",
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
candidate_ids = find_account_ids(
|
candidate_ids = find_account_ids(
|
||||||
platform_key=key,
|
platform_key=key,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
role=role,
|
role=role,
|
||||||
|
login_id=lid,
|
||||||
|
label=lab,
|
||||||
)
|
)
|
||||||
if not candidate_ids:
|
if not candidate_ids:
|
||||||
_say(_err_json("ERROR:NO_ACCOUNT",
|
hint = ""
|
||||||
"没有符合条件的可用账号(status=active 且未被其他 lease 占用)。"))
|
if lid or lab:
|
||||||
|
hint = f"(login_id={lid!r} label={lab!r})"
|
||||||
|
_say(_err_json(
|
||||||
|
"ERROR:NO_ACCOUNT",
|
||||||
|
f"没有符合条件的可用账号{hint}(status=active 且未被其他 lease 占用)。",
|
||||||
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
picked_id = candidate_ids[0]
|
picked_id = candidate_ids[0]
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ PyArmor trial per-file line limits. This module re-exports the public API.
|
|||||||
"""
|
"""
|
||||||
from service._svc_common import PlatformCallerMeta
|
from service._svc_common import PlatformCallerMeta
|
||||||
from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web
|
from service.account_add_commands import cmd_account_add_secret, cmd_account_add_web
|
||||||
|
from service.account_ensure_commands import cmd_account_ensure_web
|
||||||
|
from service.account_mutate_commands import cmd_account_copy, cmd_account_update
|
||||||
from service.account_mark_commands import (
|
from service.account_mark_commands import (
|
||||||
cmd_account_mark_error,
|
cmd_account_mark_error,
|
||||||
cmd_account_mark_session,
|
cmd_account_mark_session,
|
||||||
@@ -25,6 +27,7 @@ from service.account_query_commands import (
|
|||||||
cmd_account_get_credential,
|
cmd_account_get_credential,
|
||||||
cmd_account_list,
|
cmd_account_list,
|
||||||
cmd_account_pick_web,
|
cmd_account_pick_web,
|
||||||
|
cmd_account_resolve,
|
||||||
cmd_get,
|
cmd_get,
|
||||||
cmd_list_json,
|
cmd_list_json,
|
||||||
cmd_pick_web,
|
cmd_pick_web,
|
||||||
@@ -37,12 +40,20 @@ from service.legacy_commands import (
|
|||||||
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_list, cmd_lease_release
|
||||||
from service.platform_commands import cmd_platform_ensure, cmd_platform_get, cmd_platform_list
|
from service.platform_commands import (
|
||||||
|
cmd_init_db,
|
||||||
|
cmd_platform_ensure,
|
||||||
|
cmd_platform_get,
|
||||||
|
cmd_platform_list,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PlatformCallerMeta",
|
"PlatformCallerMeta",
|
||||||
"cmd_account_add_secret",
|
"cmd_account_add_secret",
|
||||||
"cmd_account_add_web",
|
"cmd_account_add_web",
|
||||||
|
"cmd_account_copy",
|
||||||
|
"cmd_account_ensure_web",
|
||||||
|
"cmd_account_update",
|
||||||
"cmd_account_get",
|
"cmd_account_get",
|
||||||
"cmd_account_get_credential",
|
"cmd_account_get_credential",
|
||||||
"cmd_account_list",
|
"cmd_account_list",
|
||||||
@@ -50,12 +61,14 @@ __all__ = [
|
|||||||
"cmd_account_mark_session",
|
"cmd_account_mark_session",
|
||||||
"cmd_account_mark_used",
|
"cmd_account_mark_used",
|
||||||
"cmd_account_pick_web",
|
"cmd_account_pick_web",
|
||||||
|
"cmd_account_resolve",
|
||||||
"cmd_credential_pick",
|
"cmd_credential_pick",
|
||||||
"cmd_credential_resolve",
|
"cmd_credential_resolve",
|
||||||
"cmd_delete_by_id",
|
"cmd_delete_by_id",
|
||||||
"cmd_delete_by_platform",
|
"cmd_delete_by_platform",
|
||||||
"cmd_delete_by_platform_phone",
|
"cmd_delete_by_platform_phone",
|
||||||
"cmd_get",
|
"cmd_get",
|
||||||
|
"cmd_init_db",
|
||||||
"cmd_lease_cleanup",
|
"cmd_lease_cleanup",
|
||||||
"cmd_lease_list",
|
"cmd_lease_list",
|
||||||
"cmd_lease_release",
|
"cmd_lease_release",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
Chromium/Edge browser profile opener.
|
Chromium/Edge browser profile opener.
|
||||||
|
|
||||||
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
|
Uses system Chrome/Edge executable with --user-data-dir (same profile as RPA shortcuts).
|
||||||
Does NOT download Playwright Chromium.
|
Does NOT download Playwright Chromium.
|
||||||
Does NOT check login status.
|
Does NOT check login status.
|
||||||
Does NOT write secret to SQLite.
|
Does NOT write secret to SQLite.
|
||||||
@@ -11,20 +11,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
|
|
||||||
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 util.logging_config import (
|
from util.logging_config import get_skill_logger
|
||||||
get_skill_logger,
|
|
||||||
subprocess_env_with_trace,
|
|
||||||
)
|
|
||||||
from util.platforms import PLATFORMS
|
from util.platforms import PLATFORMS
|
||||||
|
|
||||||
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import)
|
|
||||||
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
|
|
||||||
|
|
||||||
|
|
||||||
def _win_find_exe(candidates):
|
def _win_find_exe(candidates):
|
||||||
for p in candidates:
|
for p in candidates:
|
||||||
@@ -33,13 +25,12 @@ def _win_find_exe(candidates):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def resolve_chromium_channel():
|
def resolve_chromium_executable():
|
||||||
"""
|
"""
|
||||||
Playwright channel: 'chrome' | 'msedge' | None
|
Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None。
|
||||||
Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。
|
|
||||||
"""
|
"""
|
||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
return "chrome"
|
return None
|
||||||
|
|
||||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||||
@@ -53,7 +44,7 @@ def resolve_chromium_channel():
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
if chrome:
|
if chrome:
|
||||||
return "chrome"
|
return chrome
|
||||||
|
|
||||||
edge = _win_find_exe(
|
edge = _win_find_exe(
|
||||||
[
|
[
|
||||||
@@ -63,11 +54,56 @@ def resolve_chromium_channel():
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
if edge:
|
if edge:
|
||||||
return "msedge"
|
return edge
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_chromium_channel():
|
||||||
|
"""
|
||||||
|
Playwright channel: 'chrome' | 'msedge' | None
|
||||||
|
Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return "chrome"
|
||||||
|
|
||||||
|
exe = resolve_chromium_executable()
|
||||||
|
if not exe:
|
||||||
|
return None
|
||||||
|
if os.path.basename(exe).lower() == "msedge.exe":
|
||||||
|
return "msedge"
|
||||||
|
return "chrome"
|
||||||
|
|
||||||
|
|
||||||
|
# 给人核对/手工登录用:跳过 Chrome 首次欢迎页并最大化。不要带 Playwright 的 --enable-automation。
|
||||||
|
HUMAN_CHROME_FLAGS = (
|
||||||
|
"--start-maximized",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-search-engine-choice-screen",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def human_chrome_launch_args(profile_dir, url=None):
|
||||||
|
"""chrome.exe 之后的参数(Popen 列表形式,路径不额外加引号)。"""
|
||||||
|
profile_abs = os.path.abspath(profile_dir)
|
||||||
|
args = [f"--user-data-dir={profile_abs}", *HUMAN_CHROME_FLAGS]
|
||||||
|
cleaned = (url or "").strip()
|
||||||
|
if cleaned:
|
||||||
|
args.append(cleaned)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def human_chrome_shortcut_arguments(profile_dir, url=None):
|
||||||
|
"""Windows .lnk Arguments 字符串(user-data-dir 带引号)。"""
|
||||||
|
profile_abs = os.path.abspath(profile_dir)
|
||||||
|
parts = [f'--user-data-dir="{profile_abs}"', *HUMAN_CHROME_FLAGS]
|
||||||
|
cleaned = (url or "").strip()
|
||||||
|
if cleaned:
|
||||||
|
parts.append(cleaned)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _print_browser_install_hint():
|
def _print_browser_install_hint():
|
||||||
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
|
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
|
||||||
print(" • Chrome: https://www.google.com/chrome/")
|
print(" • Chrome: https://www.google.com/chrome/")
|
||||||
@@ -99,10 +135,67 @@ def resolve_account_browser_url(account: dict, conn=None) -> str:
|
|||||||
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
|
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_reveal_profile(account_id):
|
||||||
|
"""
|
||||||
|
在系统文件管理器中打开该账号的 profile_dir。
|
||||||
|
不写数据库、不启动浏览器。
|
||||||
|
"""
|
||||||
|
log = get_skill_logger()
|
||||||
|
init_db()
|
||||||
|
log.info("reveal_profile_start account_id=%r", account_id)
|
||||||
|
|
||||||
|
target = get_account_by_id(account_id)
|
||||||
|
if not target:
|
||||||
|
log.warning("reveal_profile_error account_id=%r not_found", account_id)
|
||||||
|
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
profile_dir = (target.get("profile_dir") or "").strip()
|
||||||
|
if not profile_dir:
|
||||||
|
log.warning("reveal_profile_error account_id=%s profile_dir_missing", account_id)
|
||||||
|
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
profile_abs = os.path.abspath(profile_dir)
|
||||||
|
os.makedirs(profile_abs, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# explorer 即使成功也常返回非 0;用 startfile 打开目录更稳妥
|
||||||
|
os.startfile(profile_abs) # type: ignore[attr-defined]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.run(["open", profile_abs], check=False)
|
||||||
|
else:
|
||||||
|
subprocess.run(["xdg-open", profile_abs], check=False)
|
||||||
|
except OSError as exc:
|
||||||
|
log.error("reveal_profile_error account_id=%s error=%s", account_id, str(exc))
|
||||||
|
print(f"ERROR:REVEAL_PROFILE_FAILED {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log.info("reveal_profile_ok account_id=%s profile_dir=%s", account_id, profile_abs)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "result",
|
||||||
|
"schema_version": 1,
|
||||||
|
"status": "success",
|
||||||
|
"message": "",
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"account_id": int(target["id"]) if target.get("id") is not None else account_id,
|
||||||
|
"profile_dir": profile_abs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cmd_open(account_id):
|
def cmd_open(account_id):
|
||||||
"""
|
"""
|
||||||
打开该账号的持久化浏览器,仅用于肉眼核对。
|
打开该账号的持久化浏览器,仅用于肉眼核对 / 手工登录 / 抓 DOM。
|
||||||
不写数据库、不判定是否已登录。
|
与 reveal-profile 同模式:本进程直接拉起系统 Chrome/Edge 后立即返回。
|
||||||
|
最大化、跳过 Chrome 首次欢迎页;不写数据库、不判定是否已登录、不起 Playwright。
|
||||||
"""
|
"""
|
||||||
log = get_skill_logger()
|
log = get_skill_logger()
|
||||||
init_db()
|
init_db()
|
||||||
@@ -112,27 +205,25 @@ def cmd_open(account_id):
|
|||||||
if not target:
|
if not target:
|
||||||
log.warning("browser_open_error account_id=%r not_found", account_id)
|
log.warning("browser_open_error account_id=%r not_found", account_id)
|
||||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||||
return
|
sys.exit(1)
|
||||||
|
|
||||||
channel = resolve_chromium_channel()
|
browser_exe = resolve_chromium_executable()
|
||||||
if not channel:
|
if not browser_exe:
|
||||||
_print_browser_install_hint()
|
channel = resolve_chromium_channel()
|
||||||
return
|
if sys.platform == "win32" or not channel:
|
||||||
|
_print_browser_install_hint()
|
||||||
try:
|
sys.exit(1)
|
||||||
import playwright # noqa: F401
|
# 非 Windows:退回 PATH 上的常见命令名
|
||||||
except ImportError:
|
browser_exe = "google-chrome" if channel == "chrome" else "microsoft-edge"
|
||||||
print(
|
|
||||||
"ERROR:需要 playwright Python 包。"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
profile_dir = (target.get("profile_dir") or "").strip()
|
profile_dir = (target.get("profile_dir") or "").strip()
|
||||||
if not profile_dir:
|
if not profile_dir:
|
||||||
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
|
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
|
||||||
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
|
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
|
||||||
return
|
sys.exit(1)
|
||||||
os.makedirs(profile_dir, exist_ok=True)
|
|
||||||
|
profile_abs = os.path.abspath(profile_dir)
|
||||||
|
os.makedirs(profile_abs, exist_ok=True)
|
||||||
|
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
@@ -140,37 +231,66 @@ def cmd_open(account_id):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# 与 profile_shortcut 同一套人用参数:独立 user-data-dir 保存登录态
|
||||||
|
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)]
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
|
"browser_open_launch exe=%s profile_dir=%s url=%s platform=%s",
|
||||||
profile_dir,
|
browser_exe,
|
||||||
|
profile_abs,
|
||||||
url,
|
url,
|
||||||
channel,
|
|
||||||
target.get("platform_key"),
|
target.get("platform_key"),
|
||||||
)
|
)
|
||||||
|
|
||||||
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
|
|
||||||
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
|
|
||||||
print(f"地址:{url}")
|
|
||||||
print("本命令不做登录检测。")
|
|
||||||
|
|
||||||
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="w", suffix=".json", delete=False, encoding="utf-8"
|
|
||||||
) as jf:
|
|
||||||
json.dump(cfg, jf, ensure_ascii=False)
|
|
||||||
cfg_path = jf.name
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
# GUI 浏览器:不挂控制台黑框;不等待关闭(与 reveal 一致立刻返回)
|
||||||
[sys.executable, "-m", "service.open_child_runner", cfg_path],
|
popen_kwargs: dict = {
|
||||||
cwd=_SCRIPTS_ROOT,
|
"stdin": subprocess.DEVNULL,
|
||||||
env=subprocess_env_with_trace(),
|
"stdout": subprocess.DEVNULL,
|
||||||
|
"stderr": subprocess.DEVNULL,
|
||||||
|
"close_fds": True,
|
||||||
|
}
|
||||||
|
if os.path.isabs(browser_exe):
|
||||||
|
popen_kwargs["cwd"] = os.path.dirname(browser_exe)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
else:
|
||||||
|
popen_kwargs["start_new_session"] = True
|
||||||
|
child = subprocess.Popen(cmd, **popen_kwargs)
|
||||||
|
except OSError as exc:
|
||||||
|
log.error("browser_open_error account_id=%s error=%s", account_id, str(exc))
|
||||||
|
print(f"ERROR:BROWSER_OPEN_FAILED {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
browser_name = (
|
||||||
|
"Microsoft Edge"
|
||||||
|
if os.path.basename(browser_exe).lower() == "msedge.exe"
|
||||||
|
else "Google Chrome"
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"browser_open_ok account_id=%s browser=%s pid=%s",
|
||||||
|
account_id,
|
||||||
|
browser_name,
|
||||||
|
child.pid,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "result",
|
||||||
|
"schema_version": 1,
|
||||||
|
"status": "success",
|
||||||
|
"message": "",
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"account_id": int(target["id"]) if target.get("id") is not None else account_id,
|
||||||
|
"account_label": target.get("account_label"),
|
||||||
|
"browser": browser_name,
|
||||||
|
"browser_exe": browser_exe,
|
||||||
|
"profile_dir": profile_abs,
|
||||||
|
"url": url,
|
||||||
|
"pid": child.pid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
|
)
|
||||||
except Exception as e:
|
|
||||||
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(cfg_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动,argv[1] 为 JSON 配置路径。"""
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from util.playwright_stealth import (
|
|
||||||
STEALTH_INIT_SCRIPT,
|
|
||||||
persistent_context_launch_parts,
|
|
||||||
stealth_enabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
with open(sys.argv[1], encoding="utf-8") as f:
|
|
||||||
c = json.load(f)
|
|
||||||
with sync_playwright() as p:
|
|
||||||
args, ignore_automation = persistent_context_launch_parts()
|
|
||||||
lc_kw = dict(
|
|
||||||
user_data_dir=c["profile_dir"],
|
|
||||||
headless=False,
|
|
||||||
channel=c["channel"],
|
|
||||||
no_viewport=True,
|
|
||||||
args=args,
|
|
||||||
)
|
|
||||||
if ignore_automation is not None:
|
|
||||||
lc_kw["ignore_default_args"] = ignore_automation
|
|
||||||
ctx = p.chromium.launch_persistent_context(**lc_kw)
|
|
||||||
if stealth_enabled():
|
|
||||||
ctx.add_init_script(STEALTH_INIT_SCRIPT)
|
|
||||||
try:
|
|
||||||
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
||||||
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
|
|
||||||
ctx.wait_for_event("close", timeout=86400000)
|
|
||||||
except Exception as e:
|
|
||||||
print(e, file=sys.stderr)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
ctx.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -6,8 +6,38 @@ from typing import Optional
|
|||||||
from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform
|
from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform
|
||||||
from db.connection import get_conn, init_db
|
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.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, is_valid_platform_key_format, seed_platforms
|
||||||
|
from util.runtime_paths import get_db_path
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_init_db() -> int:
|
||||||
|
"""幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。"""
|
||||||
|
log = get_skill_logger()
|
||||||
|
log.info("cli_start subcommand=init_db")
|
||||||
|
try:
|
||||||
|
init_db()
|
||||||
|
except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败
|
||||||
|
log.exception("init_db_failure")
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"ok": False, "skill": SKILL_SLUG, "error": str(exc)},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"skill": SKILL_SLUG,
|
||||||
|
"db_path": get_db_path(),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_platform_list(domain: Optional[str] = None) -> None:
|
def cmd_platform_list(domain: Optional[str] = None) -> None:
|
||||||
|
|||||||
53
scripts/util/account_identity.py
Normal file
53
scripts/util/account_identity.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""账号名称副本编号、登录标识类型推断。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
_COPY_SUFFIX_RE = re.compile(r"^(.*?)(?: 副本(?: (\d+))?)?$")
|
||||||
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
_CN_MOBILE_RE = re.compile(r"^1\d{10}$")
|
||||||
|
_PHONE_RE = re.compile(r"^\+?\d{8,15}$")
|
||||||
|
_PHONE_STRIP_RE = re.compile(r"[\s\-()]")
|
||||||
|
|
||||||
|
KNOWN_LOGIN_ID_TYPES = frozenset(
|
||||||
|
{"email", "username", "phone", "customer_code", "api_account", "unknown"}
|
||||||
|
)
|
||||||
|
KNOWN_ACCOUNT_STATUSES = frozenset({"active", "disabled", "needs_review"})
|
||||||
|
|
||||||
|
|
||||||
|
def infer_login_id_type(login_id: Optional[str]) -> str:
|
||||||
|
"""从登录标识猜类型;空则 unknown。"""
|
||||||
|
text = (login_id or "").strip()
|
||||||
|
if not text:
|
||||||
|
return "unknown"
|
||||||
|
if _EMAIL_RE.match(text):
|
||||||
|
return "email"
|
||||||
|
compact = _PHONE_STRIP_RE.sub("", text)
|
||||||
|
if _CN_MOBILE_RE.match(compact) or _PHONE_RE.match(compact):
|
||||||
|
return "phone"
|
||||||
|
return "username"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_login_id_type(explicit: Optional[str], login_id: Optional[str]) -> str:
|
||||||
|
"""有明确类型(且不是 unknown)时用明确值,否则按 login_id 推断。"""
|
||||||
|
typed = (explicit or "").strip()
|
||||||
|
if typed and typed != "unknown":
|
||||||
|
return typed
|
||||||
|
return infer_login_id_type(login_id)
|
||||||
|
|
||||||
|
|
||||||
|
def next_copy_label(source_label: str, existing_labels: Iterable[str]) -> str:
|
||||||
|
"""在已有名称中生成「原名 副本」/「原名 副本 2」…,避免叠「副本 副本」。"""
|
||||||
|
raw = (source_label or "").strip() or "账号"
|
||||||
|
existing = {item.strip().lower() for item in existing_labels if (item or "").strip()}
|
||||||
|
match = _COPY_SUFFIX_RE.match(raw)
|
||||||
|
stem = (match.group(1).strip() if match else raw) or raw
|
||||||
|
n = 1
|
||||||
|
while n <= 9999:
|
||||||
|
candidate = f"{stem} 副本" if n == 1 else f"{stem} 副本 {n}"
|
||||||
|
if candidate.lower() not in existing:
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
return f"{stem} 副本 {n}"
|
||||||
88
scripts/util/profile_shortcut.py
Normal file
88
scripts/util/profile_shortcut.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""在 Profile 目录内创建浏览器快捷方式(Windows .lnk)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from service.browser_service import (
|
||||||
|
human_chrome_shortcut_arguments,
|
||||||
|
resolve_chromium_executable,
|
||||||
|
)
|
||||||
|
|
||||||
|
_WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
def _lnk_safe_filename(name: str, fallback: str = "browser") -> str:
|
||||||
|
t = _WIN_LNK_FORBIDDEN.sub("_", (name or "").strip())
|
||||||
|
t = t.rstrip(" .")
|
||||||
|
return t or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _ps_single_quote(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def create_profile_browser_shortcut(
|
||||||
|
profile_dir: str,
|
||||||
|
account_label: str,
|
||||||
|
log: Optional[logging.Logger] = None,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
在 profile_dir 内创建 {account_label}.lnk,与「打开浏览器」同一套人用参数。
|
||||||
|
|
||||||
|
非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。
|
||||||
|
成功返回 .lnk 绝对路径。
|
||||||
|
"""
|
||||||
|
if log is None:
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
log.debug("profile_shortcut_skip platform=%s", sys.platform)
|
||||||
|
return None
|
||||||
|
|
||||||
|
browser_exe = resolve_chromium_executable()
|
||||||
|
if not browser_exe:
|
||||||
|
log.warning("profile_shortcut_skip reason=no_chromium")
|
||||||
|
return None
|
||||||
|
|
||||||
|
profile_abs = os.path.abspath(profile_dir)
|
||||||
|
os.makedirs(profile_abs, exist_ok=True)
|
||||||
|
|
||||||
|
lnk_name = f"{_lnk_safe_filename(account_label)}.lnk"
|
||||||
|
lnk_path = os.path.join(profile_abs, lnk_name)
|
||||||
|
arguments = human_chrome_shortcut_arguments(profile_abs, url)
|
||||||
|
working_dir = os.path.dirname(browser_exe)
|
||||||
|
|
||||||
|
ps = (
|
||||||
|
"$WshShell = New-Object -ComObject WScript.Shell; "
|
||||||
|
f"$s = $WshShell.CreateShortcut({_ps_single_quote(lnk_path)}); "
|
||||||
|
f"$s.TargetPath = {_ps_single_quote(browser_exe)}; "
|
||||||
|
f"$s.Arguments = {_ps_single_quote(arguments)}; "
|
||||||
|
f"$s.IconLocation = {_ps_single_quote(browser_exe + ',0')}; "
|
||||||
|
f"$s.WorkingDirectory = {_ps_single_quote(working_dir)}; "
|
||||||
|
"$s.Save()"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.CalledProcessError) as exc:
|
||||||
|
log.warning("profile_shortcut_failed profile_dir=%s error=%s", profile_abs, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not os.path.isfile(lnk_path):
|
||||||
|
log.warning("profile_shortcut_missing_after_create path=%s", lnk_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
log.info("profile_shortcut_created path=%s browser=%s", lnk_path, browser_exe)
|
||||||
|
return lnk_path
|
||||||
153
tests/test_account_copy_update.py
Normal file
153
tests/test_account_copy_update.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""account copy / update 与数据管理短表单语义。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from db.accounts_repo import find_accounts, get_account_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def _add(clean_db, **kwargs):
|
||||||
|
from service.account_service import cmd_account_add_web
|
||||||
|
|
||||||
|
defaults = dict(
|
||||||
|
platform_input="douyin",
|
||||||
|
login_id="13800138000",
|
||||||
|
login_id_type="phone",
|
||||||
|
label="抖音 默认",
|
||||||
|
tenant_id="t1",
|
||||||
|
environment="production",
|
||||||
|
role="default",
|
||||||
|
provider_code=None,
|
||||||
|
url="https://www.douyin.com",
|
||||||
|
extra_json_str='{"hook":"keep"}',
|
||||||
|
profile_dir_override=None,
|
||||||
|
auth_strategy="qr_code_manual",
|
||||||
|
session_persistent=1,
|
||||||
|
device_fingerprint=None,
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
cmd_account_add_web(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountCopy:
|
||||||
|
def test_copy_clones_platform_not_login_or_profile(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_account_copy
|
||||||
|
|
||||||
|
_add(clean_db)
|
||||||
|
source = find_accounts(platform_key="douyin")[0]
|
||||||
|
cmd_account_copy(source["id"])
|
||||||
|
out = capsys.readouterr().out.strip()
|
||||||
|
payload = json.loads(out.splitlines()[-1])
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["type"] == "result"
|
||||||
|
assert "已复制为" in payload["message"]
|
||||||
|
copy_id = payload["data"]["id"]
|
||||||
|
assert copy_id != source["id"]
|
||||||
|
copied = get_account_by_id(copy_id)
|
||||||
|
assert copied["platform_key"] == "douyin"
|
||||||
|
assert copied["account_label"] == "抖音 默认 副本"
|
||||||
|
assert copied["login_id"] == ""
|
||||||
|
assert copied["tenant_id"] == "t1"
|
||||||
|
assert copied["environment"] == "production"
|
||||||
|
assert copied["role"] == "default"
|
||||||
|
assert copied["url"] == "https://www.douyin.com"
|
||||||
|
assert copied["auth_strategy"] == "qr_code_manual"
|
||||||
|
assert copied["session_persistent"] == 1
|
||||||
|
assert copied["device_fingerprint"] is None
|
||||||
|
assert copied["status"] == "active"
|
||||||
|
assert copied["session_status"] == "unknown"
|
||||||
|
assert (copied.get("extra_json") or {}).get("hook") == "keep"
|
||||||
|
assert copied["profile_dir"]
|
||||||
|
assert copied["profile_dir"] != source["profile_dir"]
|
||||||
|
assert os.path.isdir(copied["profile_dir"])
|
||||||
|
|
||||||
|
def test_copy_second_increments_label(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_account_copy
|
||||||
|
|
||||||
|
_add(clean_db)
|
||||||
|
source = find_accounts(platform_key="douyin")[0]
|
||||||
|
cmd_account_copy(source["id"])
|
||||||
|
capsys.readouterr()
|
||||||
|
cmd_account_copy(source["id"])
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert payload["data"]["account_label"] == "抖音 默认 副本 2"
|
||||||
|
|
||||||
|
def test_copy_missing_id(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_account_copy
|
||||||
|
|
||||||
|
cmd_account_copy(99999)
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert payload["error"]["code"] == "ERROR:ACCOUNT_NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountUpdate:
|
||||||
|
def test_update_short_fields(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_account_update
|
||||||
|
|
||||||
|
_add(clean_db)
|
||||||
|
source = find_accounts(platform_key="douyin")[0]
|
||||||
|
cmd_account_update(
|
||||||
|
source["id"],
|
||||||
|
label="抖音 备用",
|
||||||
|
login_id="backup@example.com",
|
||||||
|
login_id_type="unknown",
|
||||||
|
url="https://www.douyin.com/user",
|
||||||
|
status="disabled",
|
||||||
|
)
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["message"] == "已保存"
|
||||||
|
updated = get_account_by_id(source["id"])
|
||||||
|
assert updated["account_label"] == "抖音 备用"
|
||||||
|
assert updated["login_id"] == "backup@example.com"
|
||||||
|
assert updated["login_id_type"] == "email"
|
||||||
|
assert updated["url"] == "https://www.douyin.com/user"
|
||||||
|
assert updated["status"] == "disabled"
|
||||||
|
assert updated["auth_strategy"] == "qr_code_manual"
|
||||||
|
assert updated["profile_dir"] == source["profile_dir"]
|
||||||
|
|
||||||
|
def test_update_empty_login_clears(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_account_update
|
||||||
|
|
||||||
|
_add(clean_db)
|
||||||
|
source = find_accounts(platform_key="douyin")[0]
|
||||||
|
cmd_account_update(source["id"], login_id="", login_id_type="unknown")
|
||||||
|
capsys.readouterr()
|
||||||
|
updated = get_account_by_id(source["id"])
|
||||||
|
assert updated["login_id"] == ""
|
||||||
|
assert updated["login_id_type"] == "unknown"
|
||||||
|
assert updated["account_label"] == "抖音 默认"
|
||||||
|
|
||||||
|
def test_update_rejects_bad_status(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_account_update
|
||||||
|
|
||||||
|
_add(clean_db)
|
||||||
|
source = find_accounts(platform_key="douyin")[0]
|
||||||
|
cmd_account_update(source["id"], status="nope")
|
||||||
|
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert payload["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddWebInfersLoginType:
|
||||||
|
def test_email_without_type(self, clean_db, capsys):
|
||||||
|
from service.account_service import cmd_account_add_web
|
||||||
|
|
||||||
|
cmd_account_add_web(
|
||||||
|
platform_input="deepseek",
|
||||||
|
login_id="a@b.com",
|
||||||
|
login_id_type=None,
|
||||||
|
label="ds",
|
||||||
|
tenant_id=None,
|
||||||
|
environment="production",
|
||||||
|
role="default",
|
||||||
|
provider_code=None,
|
||||||
|
url=None,
|
||||||
|
extra_json_str=None,
|
||||||
|
profile_dir_override=None,
|
||||||
|
)
|
||||||
|
capsys.readouterr()
|
||||||
|
acc = find_accounts(platform_key="deepseek")[0]
|
||||||
|
assert acc["login_id_type"] == "email"
|
||||||
27
tests/test_account_identity.py
Normal file
27
tests/test_account_identity.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from util.account_identity import infer_login_id_type, next_copy_label, resolve_login_id_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_login_id_type():
|
||||||
|
assert infer_login_id_type(None) == "unknown"
|
||||||
|
assert infer_login_id_type("") == "unknown"
|
||||||
|
assert infer_login_id_type("demo@maersk.com") == "email"
|
||||||
|
assert infer_login_id_type("13800138000") == "phone"
|
||||||
|
assert infer_login_id_type("+8613800138000") == "phone"
|
||||||
|
assert infer_login_id_type("creator_01") == "username"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_login_id_type_keeps_explicit():
|
||||||
|
assert resolve_login_id_type("phone", "demo@x.com") == "phone"
|
||||||
|
assert resolve_login_id_type("unknown", "demo@x.com") == "email"
|
||||||
|
assert resolve_login_id_type(None, "13800138000") == "phone"
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_copy_label_increments():
|
||||||
|
assert next_copy_label("抖音 默认", []) == "抖音 默认 副本"
|
||||||
|
assert next_copy_label("抖音 默认", ["抖音 默认 副本"]) == "抖音 默认 副本 2"
|
||||||
|
assert next_copy_label("抖音 默认 副本", ["抖音 默认 副本"]) == "抖音 默认 副本 2"
|
||||||
|
assert next_copy_label(
|
||||||
|
"抖音 默认",
|
||||||
|
["抖音 默认 副本", "抖音 默认 副本 2"],
|
||||||
|
) == "抖音 默认 副本 3"
|
||||||
119
tests/test_account_resolve_pick.py
Normal file
119
tests/test_account_resolve_pick.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Human-friendly account resolve / pick-web --login-id / ensure by login_id."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(fn, *args, **kwargs):
|
||||||
|
buf = io.StringIO()
|
||||||
|
old = sys.stdout
|
||||||
|
sys.stdout = buf
|
||||||
|
try:
|
||||||
|
fn(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
sys.stdout = old
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_web(platform, login_id, label):
|
||||||
|
from service.account_service import cmd_account_add_web
|
||||||
|
|
||||||
|
payload = json.loads(
|
||||||
|
_capture(
|
||||||
|
cmd_account_add_web,
|
||||||
|
platform,
|
||||||
|
login_id,
|
||||||
|
"username",
|
||||||
|
label,
|
||||||
|
None,
|
||||||
|
"production",
|
||||||
|
"default",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
|
||||||
|
return payload["data"]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountResolve:
|
||||||
|
def test_resolve_by_login_id(self, clean_db):
|
||||||
|
from service.account_service import cmd_account_resolve
|
||||||
|
|
||||||
|
created = _add_web("maersk", "chendelian", "陈德联")
|
||||||
|
raw = _capture(
|
||||||
|
cmd_account_resolve,
|
||||||
|
platform_input="maersk",
|
||||||
|
login_id="ChenDelian",
|
||||||
|
)
|
||||||
|
acc = json.loads(raw)
|
||||||
|
assert acc.get("success") is not False
|
||||||
|
assert acc["id"] == created["id"]
|
||||||
|
assert acc["login_id"] == "chendelian"
|
||||||
|
|
||||||
|
def test_resolve_by_query_fallback_label(self, clean_db):
|
||||||
|
from service.account_service import cmd_account_resolve
|
||||||
|
|
||||||
|
created = _add_web("maersk", "u1", "我的马士基")
|
||||||
|
raw = _capture(
|
||||||
|
cmd_account_resolve,
|
||||||
|
platform_input="maersk",
|
||||||
|
query="我的马士基",
|
||||||
|
)
|
||||||
|
acc = json.loads(raw)
|
||||||
|
assert acc["id"] == created["id"]
|
||||||
|
|
||||||
|
def test_resolve_ambiguous(self, clean_db):
|
||||||
|
from service.account_service import cmd_account_resolve
|
||||||
|
|
||||||
|
_add_web("maersk", "same", "A")
|
||||||
|
_add_web("cma_cgm", "same", "B")
|
||||||
|
raw = _capture(cmd_account_resolve, login_id="same")
|
||||||
|
err = json.loads(raw)
|
||||||
|
assert err["success"] is False
|
||||||
|
assert err["error"]["code"] == "ERROR:AMBIGUOUS_ACCOUNT"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPickByLoginId:
|
||||||
|
def test_pick_web_filters_login_id(self, clean_db):
|
||||||
|
from service.account_service import cmd_account_pick_web
|
||||||
|
|
||||||
|
a = _add_web("maersk", "alice", "A")
|
||||||
|
_add_web("maersk", "bob", "B")
|
||||||
|
raw = _capture(
|
||||||
|
cmd_account_pick_web,
|
||||||
|
"maersk",
|
||||||
|
login_id="bob",
|
||||||
|
)
|
||||||
|
picked = json.loads(raw)
|
||||||
|
assert picked["login_id"] == "bob"
|
||||||
|
assert picked["id"] != a["id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureByLoginId:
|
||||||
|
def test_ensure_creates_named_even_if_platform_has_others(self, clean_db):
|
||||||
|
from db.accounts_repo import find_accounts
|
||||||
|
from service.account_service import cmd_account_ensure_web
|
||||||
|
|
||||||
|
_add_web("maersk", "existing", "已有")
|
||||||
|
before = find_accounts(platform_key="maersk", status="active", limit=50)
|
||||||
|
assert len(before) == 1
|
||||||
|
|
||||||
|
raw = _capture(
|
||||||
|
cmd_account_ensure_web,
|
||||||
|
"maersk",
|
||||||
|
login_id="chendelian",
|
||||||
|
label="陈德联",
|
||||||
|
)
|
||||||
|
result = json.loads(raw)
|
||||||
|
assert result.get("account_ensured") is True
|
||||||
|
assert result["login_id"] == "chendelian"
|
||||||
|
|
||||||
|
after = find_accounts(platform_key="maersk", status="active", limit=50)
|
||||||
|
assert len(after) == 2
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
"""
|
"""
|
||||||
Browser launch helper checks (no real browser started).
|
Browser launch helper checks (no real browser started).
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -14,24 +17,100 @@ class TestPlaywrightLaunchHelpers:
|
|||||||
args, _ignore = persistent_context_launch_parts()
|
args, _ignore = persistent_context_launch_parts()
|
||||||
assert "--start-maximized" in args
|
assert "--start-maximized" in args
|
||||||
|
|
||||||
def test_open_child_runner_launch_keywords_documented(self):
|
|
||||||
"""launch_persistent_context kwargs contract (static read of source)."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
path = os.path.join(
|
class TestCmdOpenDirectBrowser:
|
||||||
os.path.dirname(__file__),
|
"""account open = same-process Chrome/Edge launch (like reveal-profile)."""
|
||||||
"..",
|
|
||||||
"scripts",
|
def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
|
||||||
"service",
|
from db.accounts_repo import insert_account
|
||||||
"open_child_runner.py",
|
from service import browser_service
|
||||||
|
from util.platforms import seed_platforms
|
||||||
|
|
||||||
|
seed_platforms(clean_db)
|
||||||
|
profile = tmp_path / "prof"
|
||||||
|
aid = insert_account(
|
||||||
|
clean_db,
|
||||||
|
platform_key="sohu",
|
||||||
|
account_label="open-test",
|
||||||
|
login_id="1",
|
||||||
|
login_id_type="phone",
|
||||||
|
tenant_id=None,
|
||||||
|
environment="production",
|
||||||
|
role="default",
|
||||||
|
provider_code="sohu",
|
||||||
|
profile_dir=str(profile),
|
||||||
|
url="https://example.com",
|
||||||
|
extra_json=None,
|
||||||
|
now=1_700_000_000,
|
||||||
|
auth_strategy="password_auto",
|
||||||
)
|
)
|
||||||
path = os.path.abspath(path)
|
clean_db.commit()
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
src = f.read()
|
fake_exe = str(tmp_path / "chrome.exe")
|
||||||
assert "launch_persistent_context" in src
|
monkeypatch.setattr(
|
||||||
assert "headless=False" in src
|
browser_service,
|
||||||
assert "no_viewport=True" in src
|
"resolve_chromium_executable",
|
||||||
assert '"channel"' in src or "'channel'" in src
|
lambda: fake_exe,
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeProc:
|
||||||
|
pid = 5252
|
||||||
|
|
||||||
|
spawned = {}
|
||||||
|
|
||||||
|
def fake_popen(cmd, **kwargs):
|
||||||
|
spawned["cmd"] = list(cmd)
|
||||||
|
spawned["kwargs"] = kwargs
|
||||||
|
return FakeProc()
|
||||||
|
|
||||||
|
monkeypatch.setattr(browser_service.subprocess, "Popen", fake_popen)
|
||||||
|
|
||||||
|
browser_service.cmd_open(str(aid))
|
||||||
|
out = capsys.readouterr().out.strip()
|
||||||
|
payload = json.loads(out.splitlines()[-1])
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["message"] == ""
|
||||||
|
assert payload["type"] == "result"
|
||||||
|
assert payload["status"] == "success"
|
||||||
|
assert payload["data"]["pid"] == 5252
|
||||||
|
assert payload["data"]["browser_exe"] == fake_exe
|
||||||
|
assert spawned["cmd"][0] == fake_exe
|
||||||
|
assert spawned["cmd"][1].startswith("--user-data-dir=")
|
||||||
|
assert "--start-maximized" in spawned["cmd"]
|
||||||
|
assert "--no-first-run" in spawned["cmd"]
|
||||||
|
assert "--no-default-browser-check" in spawned["cmd"]
|
||||||
|
assert "--disable-search-engine-choice-screen" in spawned["cmd"]
|
||||||
|
assert spawned["cmd"][-1] == "https://example.com"
|
||||||
|
assert "--enable-automation" not in spawned["cmd"]
|
||||||
|
# must NOT spawn python / open_child_runner
|
||||||
|
joined = " ".join(spawned["cmd"])
|
||||||
|
assert "open_child_runner" not in joined
|
||||||
|
assert "python" not in joined.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHumanChromeLaunchArgs:
|
||||||
|
def test_flags_then_url(self, tmp_path):
|
||||||
|
from service.browser_service import HUMAN_CHROME_FLAGS, human_chrome_launch_args
|
||||||
|
|
||||||
|
profile = str(tmp_path / "prof")
|
||||||
|
args = human_chrome_launch_args(profile, "https://chat.deepseek.com")
|
||||||
|
assert args[0].startswith("--user-data-dir=")
|
||||||
|
for flag in HUMAN_CHROME_FLAGS:
|
||||||
|
assert flag in args
|
||||||
|
assert args[-1] == "https://chat.deepseek.com"
|
||||||
|
assert "--enable-automation" not in args
|
||||||
|
|
||||||
|
def test_shortcut_arguments_quote_profile_and_include_url(self, tmp_path):
|
||||||
|
from service.browser_service import human_chrome_shortcut_arguments
|
||||||
|
|
||||||
|
profile = tmp_path / "my prof"
|
||||||
|
profile.mkdir()
|
||||||
|
text = human_chrome_shortcut_arguments(str(profile), "https://example.com")
|
||||||
|
assert '--user-data-dir="' in text
|
||||||
|
assert "--start-maximized" in text
|
||||||
|
assert "--no-first-run" in text
|
||||||
|
assert text.endswith("https://example.com")
|
||||||
|
|
||||||
|
|
||||||
class TestResolveChromiumChannel:
|
class TestResolveChromiumChannel:
|
||||||
|
|||||||
357
tests/test_display_metadata.py
Normal file
357
tests/test_display_metadata.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""数据管理展示元数据:幂等初始化、中文名称、敏感字段权限。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from db.display_metadata import (
|
||||||
|
ACCOUNT_LEASES_TABLE,
|
||||||
|
ACCOUNTS_TABLE,
|
||||||
|
CREDENTIALS_TABLE,
|
||||||
|
PLATFORMS_TABLE,
|
||||||
|
USER_BUSINESS_TABLES,
|
||||||
|
get_table_readonly,
|
||||||
|
init_display_metadata_tables,
|
||||||
|
list_physical_column_names,
|
||||||
|
table_allows_manual_create,
|
||||||
|
upsert_column_metadata,
|
||||||
|
upsert_table_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_cjk(text: str) -> bool:
|
||||||
|
return bool(re.search(r"[\u4e00-\u9fff]", text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metadata_db(clean_db):
|
||||||
|
"""clean_db already ran init_db(); return connection for metadata assertions."""
|
||||||
|
yield clean_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_metadata_idempotent(metadata_db):
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
|
||||||
|
count_after_second = int(cur.fetchone()[0])
|
||||||
|
assert count_after_second == len(USER_BUSINESS_TABLES)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"table_name,expected_display",
|
||||||
|
[
|
||||||
|
(PLATFORMS_TABLE, "平台信息"),
|
||||||
|
(ACCOUNTS_TABLE, "账号信息"),
|
||||||
|
(CREDENTIALS_TABLE, "凭证信息"),
|
||||||
|
(ACCOUNT_LEASES_TABLE, "账号租约"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_table_chinese_display_names(metadata_db, table_name, expected_display):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == expected_display
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_business_columns_have_chinese_display_names(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
for table_name in USER_BUSINESS_TABLES:
|
||||||
|
physical = set(list_physical_column_names(cur, table_name))
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, display_name
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ?
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
meta_rows = cur.fetchall()
|
||||||
|
assert len(meta_rows) == len(physical), f"{table_name} 元数据字段数与物理表不一致"
|
||||||
|
for column_name, display_name in meta_rows:
|
||||||
|
assert column_name in physical
|
||||||
|
assert _has_cjk(str(display_name)), f"{table_name}.{column_name} 缺少中文 display_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_credential_fields_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
sensitive = ("secret_ref", "secret_mask", "secret_ciphertext", "secret_storage")
|
||||||
|
for column_name in sensitive:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable, searchable
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
""",
|
||||||
|
(CREDENTIALS_TABLE, column_name),
|
||||||
|
)
|
||||||
|
editable, searchable = cur.fetchone()
|
||||||
|
assert int(editable) == 0, column_name
|
||||||
|
if column_name in ("secret_ref", "secret_ciphertext"):
|
||||||
|
assert int(searchable) == 0, column_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_and_credential_platform_lookups(metadata_db):
|
||||||
|
import json
|
||||||
|
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
for table_name, column_name in (
|
||||||
|
(ACCOUNTS_TABLE, "platform_key"),
|
||||||
|
(CREDENTIALS_TABLE, "platform_key"),
|
||||||
|
):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT display_type, options_json
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
""",
|
||||||
|
(table_name, column_name),
|
||||||
|
)
|
||||||
|
display_type, options_json = cur.fetchone()
|
||||||
|
assert display_type == "lookup", table_name
|
||||||
|
payload = json.loads(options_json)
|
||||||
|
assert payload["ref_table"] == "platforms"
|
||||||
|
assert payload["value_column"] == "platform_key"
|
||||||
|
assert payload["label_column"] == "display_name"
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT display_type, options_json
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'account_id'
|
||||||
|
""",
|
||||||
|
(CREDENTIALS_TABLE,),
|
||||||
|
)
|
||||||
|
display_type, options_json = cur.fetchone()
|
||||||
|
assert display_type == "lookup"
|
||||||
|
payload = json.loads(options_json)
|
||||||
|
assert payload["ref_table"] == "accounts"
|
||||||
|
assert payload["value_column"] == "id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lease_token_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'lease_token'
|
||||||
|
""",
|
||||||
|
(ACCOUNT_LEASES_TABLE,),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_leases_readonly(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
assert int(get_table_readonly(cur, ACCOUNT_LEASES_TABLE)) == 1
|
||||||
|
assert table_allows_manual_create(cur, ACCOUNT_LEASES_TABLE) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_platforms_key_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'platform_key'
|
||||||
|
""",
|
||||||
|
(PLATFORMS_TABLE,),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_dir_not_editable(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'profile_dir'
|
||||||
|
""",
|
||||||
|
(ACCOUNTS_TABLE,),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_edit_form_columns_and_enums(metadata_db):
|
||||||
|
import json
|
||||||
|
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
expected_editable = {
|
||||||
|
"account_label",
|
||||||
|
"login_id",
|
||||||
|
"login_id_type",
|
||||||
|
"url",
|
||||||
|
"status",
|
||||||
|
}
|
||||||
|
readonly_runtime = {
|
||||||
|
"platform_key",
|
||||||
|
"tenant_id",
|
||||||
|
"environment",
|
||||||
|
"role",
|
||||||
|
"provider_code",
|
||||||
|
"session_status",
|
||||||
|
"auth_strategy",
|
||||||
|
"session_persistent",
|
||||||
|
"device_fingerprint",
|
||||||
|
}
|
||||||
|
for column_name in expected_editable:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
""",
|
||||||
|
(ACCOUNTS_TABLE, column_name),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 1, column_name
|
||||||
|
for column_name in readonly_runtime:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT editable FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = ?
|
||||||
|
""",
|
||||||
|
(ACCOUNTS_TABLE, column_name),
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0, column_name
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT options_json FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name = 'status'
|
||||||
|
""",
|
||||||
|
(ACCOUNTS_TABLE,),
|
||||||
|
)
|
||||||
|
options = json.loads(cur.fetchone()[0])
|
||||||
|
values = {item["value"] for item in options}
|
||||||
|
labels = {item["label"] for item in options}
|
||||||
|
assert values == {"active", "disabled", "needs_review"}
|
||||||
|
assert "启用" in labels
|
||||||
|
assert "停用" in labels
|
||||||
|
|
||||||
|
|
||||||
|
def test_timestamp_columns_use_datetime_display_type(metadata_db):
|
||||||
|
cur = metadata_db.cursor()
|
||||||
|
for table_name in USER_BUSINESS_TABLES:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, display_type, editable
|
||||||
|
FROM _jiangchang_columns
|
||||||
|
WHERE table_name = ? AND column_name IN ('created_at', 'updated_at')
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
for column_name, display_type, editable in cur.fetchall():
|
||||||
|
assert display_type == "datetime_unix_seconds", f"{table_name}.{column_name}"
|
||||||
|
assert int(editable) == 0, f"{table_name}.{column_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_tables_hidden_from_user_tables(metadata_db):
|
||||||
|
from db.display_metadata import iter_user_tables
|
||||||
|
|
||||||
|
tables = list(iter_user_tables(metadata_db.cursor()))
|
||||||
|
assert "_jiangchang_tables" not in tables
|
||||||
|
assert "_jiangchang_columns" not in tables
|
||||||
|
assert "_schema_meta" not in tables
|
||||||
|
for name in USER_BUSINESS_TABLES:
|
||||||
|
assert name in tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_reinit_clears_stale_column_metadata(metadata_db):
|
||||||
|
from db.connection import get_conn, init_db
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO _jiangchang_columns
|
||||||
|
(table_name, column_name, display_name, visible, searchable, editable)
|
||||||
|
VALUES ('accounts', 'stale_field', '陈旧字段', 1, 1, 0)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM _jiangchang_columns
|
||||||
|
WHERE table_name = 'accounts' AND column_name = 'stale_field'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) == 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_metadata_detected_in_memory_db():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from db.display_metadata import init_display_metadata, list_physical_column_names
|
||||||
|
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
try:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE platforms (
|
||||||
|
platform_key TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
init_display_metadata_tables(conn.cursor())
|
||||||
|
upsert_table_metadata(
|
||||||
|
conn.cursor(),
|
||||||
|
table_name="platforms",
|
||||||
|
display_name="平台信息",
|
||||||
|
)
|
||||||
|
upsert_column_metadata(
|
||||||
|
conn.cursor(),
|
||||||
|
table_name="platforms",
|
||||||
|
column_name="platform_key",
|
||||||
|
display_name="平台键",
|
||||||
|
editable=0,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
physical = list_physical_column_names(conn.cursor(), "platforms")
|
||||||
|
assert "display_name" in physical
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'platforms'"
|
||||||
|
)
|
||||||
|
assert int(cur.fetchone()[0]) < len(physical)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Full seed should cover all physical columns
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
try:
|
||||||
|
from db.schema import ensure_schema
|
||||||
|
|
||||||
|
ensure_schema(conn)
|
||||||
|
init_display_metadata(conn.cursor())
|
||||||
|
conn.commit()
|
||||||
|
for table_name in USER_BUSINESS_TABLES:
|
||||||
|
physical = set(list_physical_column_names(conn.cursor(), table_name))
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
meta_cols = {row[0] for row in cur.fetchall()}
|
||||||
|
assert meta_cols == physical, table_name
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
143
tests/test_ensure_web.py
Normal file
143
tests/test_ensure_web.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Tests for account ensure-web (pick-or-create)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(fn, *args, **kwargs):
|
||||||
|
buf = io.StringIO()
|
||||||
|
old = sys.stdout
|
||||||
|
sys.stdout = buf
|
||||||
|
try:
|
||||||
|
fn(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
sys.stdout = old
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureWeb:
|
||||||
|
def test_ensure_creates_when_empty(self, clean_db):
|
||||||
|
from service.account_service import cmd_account_ensure_web
|
||||||
|
|
||||||
|
raw = _capture(
|
||||||
|
cmd_account_ensure_web,
|
||||||
|
"maersk",
|
||||||
|
label="马士基默认",
|
||||||
|
)
|
||||||
|
result = json.loads(raw)
|
||||||
|
assert result.get("success") is not False
|
||||||
|
assert result["platform_key"] == "maersk"
|
||||||
|
assert result.get("profile_dir")
|
||||||
|
assert result.get("account_ensured") is True
|
||||||
|
assert result.get("id")
|
||||||
|
|
||||||
|
def test_ensure_reuses_existing_no_duplicate(self, clean_db):
|
||||||
|
from service.account_service import cmd_account_add_web, cmd_account_ensure_web
|
||||||
|
from db.accounts_repo import find_accounts
|
||||||
|
|
||||||
|
_capture(
|
||||||
|
cmd_account_add_web,
|
||||||
|
"maersk",
|
||||||
|
"a@test.com",
|
||||||
|
"email",
|
||||||
|
"已有",
|
||||||
|
None,
|
||||||
|
"production",
|
||||||
|
"default",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
before = find_accounts(platform_key="maersk", status="active", limit=50)
|
||||||
|
assert len(before) == 1
|
||||||
|
|
||||||
|
raw = _capture(cmd_account_ensure_web, "maersk")
|
||||||
|
result = json.loads(raw)
|
||||||
|
assert result.get("account_ensured") is not True
|
||||||
|
assert result["id"] == before[0]["id"]
|
||||||
|
|
||||||
|
after = find_accounts(platform_key="maersk", status="active", limit=50)
|
||||||
|
assert len(after) == 1
|
||||||
|
|
||||||
|
def test_ensure_lease_busy_does_not_create(self, clean_db):
|
||||||
|
from db.accounts_repo import acquire_lease, find_account_ids, find_accounts
|
||||||
|
from service.account_service import cmd_account_add_web, cmd_account_ensure_web
|
||||||
|
|
||||||
|
_capture(
|
||||||
|
cmd_account_add_web,
|
||||||
|
"cma_cgm",
|
||||||
|
"b@cma.com",
|
||||||
|
"email",
|
||||||
|
"CMA",
|
||||||
|
None,
|
||||||
|
"production",
|
||||||
|
"default",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
ids = find_account_ids("cma_cgm")
|
||||||
|
assert len(ids) == 1
|
||||||
|
acquire_lease(
|
||||||
|
account_id=ids[0],
|
||||||
|
lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||||
|
holder="other",
|
||||||
|
purpose="busy",
|
||||||
|
ttl_sec=900,
|
||||||
|
)
|
||||||
|
assert find_account_ids("cma_cgm") == []
|
||||||
|
|
||||||
|
raw = _capture(cmd_account_ensure_web, "cma_cgm", do_lease=True, holder="me")
|
||||||
|
err = json.loads(raw)
|
||||||
|
assert err["success"] is False
|
||||||
|
assert err["error"]["code"] == "ERROR:LEASE_CONFLICT"
|
||||||
|
|
||||||
|
still = find_accounts(platform_key="cma_cgm", status="active", limit=50)
|
||||||
|
assert len(still) == 1
|
||||||
|
|
||||||
|
def test_pick_web_ensure_flag_cli(self, clean_db, fake_env):
|
||||||
|
"""CLI account pick-web --ensure creates when empty."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
scripts = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"
|
||||||
|
)
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHONPATH": scripts,
|
||||||
|
"JIANGCHANG_DATA_ROOT": fake_env["data_root"],
|
||||||
|
"JIANGCHANG_USER_ID": fake_env["user_id"],
|
||||||
|
}
|
||||||
|
r = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"cli.app",
|
||||||
|
"account",
|
||||||
|
"pick-web",
|
||||||
|
"--platform",
|
||||||
|
"maersk",
|
||||||
|
"--ensure",
|
||||||
|
"--label",
|
||||||
|
"cli-default",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
cwd=scripts,
|
||||||
|
)
|
||||||
|
assert r.returncode == 0, r.stderr + r.stdout
|
||||||
|
# last non-empty JSON line
|
||||||
|
lines = [ln for ln in r.stdout.splitlines() if ln.strip()]
|
||||||
|
result = json.loads(lines[-1])
|
||||||
|
assert result.get("platform_key") == "maersk"
|
||||||
|
assert result.get("account_ensured") is True
|
||||||
|
assert result.get("profile_dir")
|
||||||
102
tests/test_profile_shortcut.py
Normal file
102
tests/test_profile_shortcut.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Profile 目录浏览器快捷方式测试。"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from util.profile_shortcut import _lnk_safe_filename, create_profile_browser_shortcut
|
||||||
|
|
||||||
|
|
||||||
|
class TestLnkSafeFilename:
|
||||||
|
def test_strips_forbidden_chars(self) -> None:
|
||||||
|
assert _lnk_safe_filename('小红书/测试:账号') == "小红书_测试_账号"
|
||||||
|
|
||||||
|
def test_empty_uses_fallback(self) -> None:
|
||||||
|
assert _lnk_safe_filename(" ") == "browser"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateProfileBrowserShortcut:
|
||||||
|
def test_skips_non_windows(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("util.profile_shortcut.sys.platform", "linux")
|
||||||
|
out = create_profile_browser_shortcut(str(tmp_path), "demo")
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
def test_skips_when_no_browser(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"util.profile_shortcut.resolve_chromium_executable",
|
||||||
|
lambda: None,
|
||||||
|
)
|
||||||
|
out = create_profile_browser_shortcut(str(tmp_path), "demo")
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
def test_windows_lnk_uses_human_chrome_args(self, tmp_path, monkeypatch) -> None:
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run(argv, **kwargs):
|
||||||
|
captured["argv"] = list(argv)
|
||||||
|
class Result:
|
||||||
|
returncode = 0
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"util.profile_shortcut.resolve_chromium_executable",
|
||||||
|
lambda: r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("util.profile_shortcut.subprocess.run", fake_run)
|
||||||
|
monkeypatch.setattr("util.profile_shortcut.os.path.isfile", lambda path: True)
|
||||||
|
|
||||||
|
out = create_profile_browser_shortcut(
|
||||||
|
str(tmp_path), "demo", url="https://chat.deepseek.com",
|
||||||
|
)
|
||||||
|
assert out is not None
|
||||||
|
ps = captured["argv"][-1]
|
||||||
|
assert "--start-maximized" in ps
|
||||||
|
assert "--no-first-run" in ps
|
||||||
|
assert "https://chat.deepseek.com" in ps
|
||||||
|
assert "--enable-automation" not in ps
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
|
||||||
|
def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None:
|
||||||
|
from service.browser_service import resolve_chromium_executable
|
||||||
|
|
||||||
|
browser_exe = resolve_chromium_executable()
|
||||||
|
if not browser_exe:
|
||||||
|
pytest.skip("no Chrome/Edge installed")
|
||||||
|
|
||||||
|
label = "ShortcutTest"
|
||||||
|
out = create_profile_browser_shortcut(str(tmp_path), label)
|
||||||
|
assert out is not None
|
||||||
|
assert os.path.isfile(out)
|
||||||
|
assert out.endswith(f"{label}.lnk")
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
|
||||||
|
def test_add_web_creates_shortcut(self, clean_db, fake_env, monkeypatch) -> None:
|
||||||
|
from service.browser_service import resolve_chromium_executable
|
||||||
|
from service.account_service import cmd_account_add_web
|
||||||
|
from db.accounts_repo import find_accounts
|
||||||
|
|
||||||
|
if not resolve_chromium_executable():
|
||||||
|
pytest.skip("no Chrome/Edge installed")
|
||||||
|
|
||||||
|
label = "Maersk shortcut test"
|
||||||
|
cmd_account_add_web(
|
||||||
|
platform_input="maersk",
|
||||||
|
login_id="shortcut@test.com",
|
||||||
|
login_id_type="email",
|
||||||
|
label=label,
|
||||||
|
tenant_id="tenant-test",
|
||||||
|
environment="production",
|
||||||
|
role="booking",
|
||||||
|
provider_code=None,
|
||||||
|
url=None,
|
||||||
|
extra_json_str=None,
|
||||||
|
profile_dir_override=None,
|
||||||
|
)
|
||||||
|
accs = find_accounts(platform_key="maersk")
|
||||||
|
assert len(accs) == 1
|
||||||
|
profile_dir = accs[0]["profile_dir"]
|
||||||
|
lnk_path = os.path.join(profile_dir, f"{label}.lnk")
|
||||||
|
assert os.path.isfile(lnk_path), f"expected shortcut at {lnk_path}"
|
||||||
78
tests/test_reveal_profile.py
Normal file
78
tests/test_reveal_profile.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""account reveal-profile:打开 profile 目录(不启动浏览器)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_account(conn, *, profile_dir: str) -> int:
|
||||||
|
from db.accounts_repo import insert_account
|
||||||
|
from util.platforms import seed_platforms
|
||||||
|
|
||||||
|
seed_platforms(conn)
|
||||||
|
aid = insert_account(
|
||||||
|
conn,
|
||||||
|
platform_key="sohu",
|
||||||
|
account_label="reveal-test",
|
||||||
|
login_id="13800000000",
|
||||||
|
login_id_type="phone",
|
||||||
|
tenant_id=None,
|
||||||
|
environment="production",
|
||||||
|
role="default",
|
||||||
|
provider_code="sohu",
|
||||||
|
profile_dir=profile_dir,
|
||||||
|
url="https://mp.sohu.com",
|
||||||
|
extra_json=None,
|
||||||
|
now=1_700_000_000,
|
||||||
|
auth_strategy="password_auto",
|
||||||
|
session_persistent=1,
|
||||||
|
device_fingerprint=None,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return aid
|
||||||
|
|
||||||
|
|
||||||
|
def test_reveal_profile_opens_dir(clean_db, tmp_path, capsys):
|
||||||
|
from service.browser_service import cmd_reveal_profile
|
||||||
|
|
||||||
|
profile = tmp_path / "profiles" / "sohu" / "reveal-test"
|
||||||
|
aid = _insert_account(clean_db, profile_dir=str(profile))
|
||||||
|
|
||||||
|
with mock.patch("service.browser_service.os.startfile") as startfile:
|
||||||
|
with mock.patch("service.browser_service.sys.platform", "win32"):
|
||||||
|
cmd_reveal_profile(str(aid))
|
||||||
|
|
||||||
|
startfile.assert_called_once()
|
||||||
|
called_path = startfile.call_args[0][0]
|
||||||
|
assert os.path.isdir(called_path)
|
||||||
|
assert os.path.abspath(str(profile)) == called_path
|
||||||
|
|
||||||
|
out = capsys.readouterr().out.strip()
|
||||||
|
payload = json.loads(out.splitlines()[-1])
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["message"] == ""
|
||||||
|
assert payload["type"] == "result"
|
||||||
|
assert payload["status"] == "success"
|
||||||
|
assert payload["data"]["account_id"] == aid
|
||||||
|
assert payload["data"]["profile_dir"] == called_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_reveal_profile_missing_account(clean_db):
|
||||||
|
from service.browser_service import cmd_reveal_profile
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as ei:
|
||||||
|
cmd_reveal_profile("999999")
|
||||||
|
assert ei.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reveal_profile_empty_dir(clean_db):
|
||||||
|
from service.browser_service import cmd_reveal_profile
|
||||||
|
|
||||||
|
aid = _insert_account(clean_db, profile_dir="")
|
||||||
|
with pytest.raises(SystemExit) as ei:
|
||||||
|
cmd_reveal_profile(str(aid))
|
||||||
|
assert ei.value.code == 1
|
||||||
74
tests/test_row_open_actions.py
Normal file
74
tests/test_row_open_actions.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""数据管理行内打开类:label 对齐模板,不与宿主 CRUD 重复。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_root() -> str:
|
||||||
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_actions() -> list[dict]:
|
||||||
|
path = os.path.join(_skill_root(), "assets", "actions.json")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
return list(manifest.get("actions") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _by_id(actions: list[dict], action_id: str) -> dict:
|
||||||
|
for item in actions:
|
||||||
|
if item.get("id") == action_id:
|
||||||
|
return item
|
||||||
|
raise AssertionError(f"missing action {action_id}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRowOpenActions(unittest.TestCase):
|
||||||
|
def test_reveal_and_open_use_template_labels(self) -> None:
|
||||||
|
actions = _load_actions()
|
||||||
|
reveal = _by_id(actions, "reveal-profile")
|
||||||
|
open_browser = _by_id(actions, "open-browser")
|
||||||
|
self.assertEqual(reveal.get("label"), "打开目录")
|
||||||
|
self.assertEqual(open_browser.get("label"), "打开浏览器")
|
||||||
|
self.assertEqual(reveal.get("placements"), ["row"])
|
||||||
|
self.assertEqual(open_browser.get("placements"), ["row"])
|
||||||
|
self.assertEqual(reveal.get("interaction"), "direct")
|
||||||
|
self.assertEqual(open_browser.get("interaction"), "direct")
|
||||||
|
self.assertFalse(reveal.get("replaces"))
|
||||||
|
self.assertFalse(open_browser.get("replaces"))
|
||||||
|
|
||||||
|
def test_create_takes_over_host_add_without_row_duplicate(self) -> None:
|
||||||
|
create = _by_id(_load_actions(), "account-add-web")
|
||||||
|
self.assertEqual(create.get("replaces"), "create")
|
||||||
|
self.assertEqual(create.get("placements") or [], [])
|
||||||
|
self.assertEqual(create.get("label"), "新增账号")
|
||||||
|
props = (create.get("inputSchema") or {}).get("properties") or {}
|
||||||
|
self.assertEqual(set(props), {"platform_key", "account_label", "login_id"})
|
||||||
|
|
||||||
|
def test_update_takes_over_host_edit(self) -> None:
|
||||||
|
update = _by_id(_load_actions(), "account-update")
|
||||||
|
self.assertEqual(update.get("replaces"), "update")
|
||||||
|
self.assertEqual(update.get("placements") or [], [])
|
||||||
|
self.assertEqual(update.get("label"), "编辑账号")
|
||||||
|
props = (update.get("inputSchema") or {}).get("properties") or {}
|
||||||
|
self.assertEqual(
|
||||||
|
set(props),
|
||||||
|
{"accountId", "account_label", "login_id", "login_id_type", "url", "status"},
|
||||||
|
)
|
||||||
|
self.assertIn("enum", props["login_id_type"])
|
||||||
|
self.assertIn("enum", props["status"])
|
||||||
|
|
||||||
|
def test_copy_is_direct_row_action(self) -> None:
|
||||||
|
copy = _by_id(_load_actions(), "account-copy")
|
||||||
|
self.assertEqual(copy.get("label"), "复制")
|
||||||
|
self.assertEqual(copy.get("placements"), ["row"])
|
||||||
|
self.assertEqual(copy.get("interaction"), "direct")
|
||||||
|
self.assertFalse(copy.get("replaces"))
|
||||||
|
steps = copy.get("steps") or []
|
||||||
|
self.assertTrue(any(s.get("do") == "reloadTable" for s in steps))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -23,11 +23,60 @@ def test_usage_includes_all_account_subcommands():
|
|||||||
assert r.returncode == 1
|
assert r.returncode == 1
|
||||||
usage = r.stdout
|
usage = r.stdout
|
||||||
for cmd in (
|
for cmd in (
|
||||||
|
"init-db",
|
||||||
"init-master-key",
|
"init-master-key",
|
||||||
"export-credentials",
|
"export-credentials",
|
||||||
"import-credentials",
|
"import-credentials",
|
||||||
|
"copy",
|
||||||
|
"update",
|
||||||
"add-web",
|
"add-web",
|
||||||
|
"ensure-web",
|
||||||
"get-credential",
|
"get-credential",
|
||||||
"pick-web",
|
"pick-web",
|
||||||
|
"resolve",
|
||||||
|
"reveal-profile",
|
||||||
):
|
):
|
||||||
assert cmd in usage, f"USAGE 缺少 {cmd}"
|
assert cmd in usage, f"USAGE 缺少 {cmd}"
|
||||||
|
assert "--ensure" in usage
|
||||||
|
assert "--login-id" in usage
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_db_cli_succeeds(fake_env):
|
||||||
|
"""宿主调用的 init-db 必须成功并写入展示元数据。"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHONPATH": _scripts,
|
||||||
|
"JIANGCHANG_DATA_ROOT": fake_env["data_root"],
|
||||||
|
"JIANGCHANG_USER_ID": fake_env["user_id"],
|
||||||
|
}
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, "main.py", "init-db"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
cwd=_scripts,
|
||||||
|
)
|
||||||
|
assert r.returncode == 0, r.stdout + r.stderr
|
||||||
|
payload = json.loads(r.stdout.strip().splitlines()[-1])
|
||||||
|
assert payload.get("ok") is True
|
||||||
|
assert payload.get("skill") == "account-manager"
|
||||||
|
assert payload.get("db_path")
|
||||||
|
|
||||||
|
from db.connection import get_conn
|
||||||
|
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
|
||||||
|
assert int(cur.fetchone()[0]) > 0
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT display_name FROM _jiangchang_columns
|
||||||
|
WHERE table_name = 'accounts' AND column_name = 'role'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert cur.fetchone()[0] == "角色"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user