Compare commits

...

11 Commits

Author SHA1 Message Date
9a20e7fc69 feat: simplify account create/edit and add row copy
All checks were successful
技能自动化发布 / release (push) Successful in 8s
Data-management add/edit now use short forms with enums; copy clones a platform account into a new empty profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:48:46 +08:00
097dde0ebb fix: open account browser maximized without Chrome first-run
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Human open now uses start-maximized and no-first-run flags, and profile shortcuts share the same args plus the platform URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:11:05 +08:00
718aeefd44 fix: unify row open labels and silent success
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Rename 打开文件位置 to 打开目录, and print empty SAS message so the host skips 执行成功 after opening Explorer or the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 13:15:26 +08:00
123195fcd9 fix: align add-web form fields to column names for lookup
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 15:14:31 +08:00
ba991a2462 feat: bind accounts create to add-web via replaces
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 14:41:36 +08:00
e9455cbb86 feat: data-management lookup for platform/account fields
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 13:37:07 +08:00
87877010ac fix: account open launches Chrome/Edge directly like reveal-profile
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 11:58:18 +08:00
0f54204a61 fix: account open fire-and-forget; open-browser direct+sync
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-31 11:43:11 +08:00
f48e8aa75d feat: row actions use interaction direct/confirm
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-07-31 11:15:05 +08:00
f5bab01421 feat: 数据管理行内打开文件位置与打开浏览器
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 10:02:00 +08:00
cbbec7cdfc fix: add init-db CLI for host install/upgrade metadata seed
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-26 16:03:27 +08:00
27 changed files with 1654 additions and 165 deletions

View File

@@ -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.12 version: 2.0.23
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -27,19 +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 输出;list 可按 `--login-id` / `--label` 筛选。 3. **复制账号**:`account copy <id>` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。
4. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 4. **编辑账号**:`account update --id <id>` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。
5. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 5. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
6. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 6. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
7. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。 7. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
8. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 8. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
9. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 9. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
10. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 10. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。
11. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。 11. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
12. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。 12. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
13. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。 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 调用

View File

@@ -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
View 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}}"]
}
}
]
}

View File

@@ -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,7 +43,9 @@
| 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 resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>] [--label <备注>] [--query <…>]` | **用人话定位一条账号**(成功输出同 `get`);多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT` |
@@ -48,7 +56,8 @@
| `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)
@@ -68,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>` | 打开目录 |
### 兼容入口(旧版别名) ### 兼容入口(旧版别名)
@@ -101,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

View File

@@ -87,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` 无法打开目录(系统调用失败) |
--- ---

View File

@@ -37,6 +37,8 @@
| 导入加密凭据 | `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>` | 同上对应新式命令 |

View File

@@ -58,4 +58,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA
--- ---
**作者**:深圳匠厂科技有限公司 · **版本**:2.0.0 **作者**:深圳匠厂科技有限公司 · **版本**:2.0.23

View File

@@ -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|ensure-web|add-secret|get|get-credential|list|resolve|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,7 +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_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,
@@ -38,6 +40,7 @@ from service.account_service import (
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,
@@ -48,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
@@ -60,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>
@@ -67,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]
@@ -100,6 +109,7 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
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
@@ -114,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> # 兼容别名
兼容入口(旧版): 兼容入口(旧版):
@@ -217,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:
@@ -266,6 +281,13 @@ def main() -> None:
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": elif sub == "ensure-web":
_cmd_ensure_web(av) _cmd_ensure_web(av)
elif sub == "add-secret": elif sub == "add-secret":
@@ -314,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":
@@ -452,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")
@@ -496,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:
@@ -604,7 +659,7 @@ def _cmd_pick_web_new(argv: list[str]) -> None:
def _cmd_ensure_web(argv: list[str]) -> None: def _cmd_ensure_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") environment = _get_opt(argv, "--environment")
@@ -636,7 +691,7 @@ def _cmd_ensure_web(argv: list[str]) -> None:
cmd_account_ensure_web( cmd_account_ensure_web(
platform_input=plat, platform_input=plat,
login_id=login_id, login_id=login_id,
login_id_type=login_id_type or "unknown", login_id_type=login_id_type,
label=label, label=label,
tenant_id=tenant_id, tenant_id=tenant_id,
environment=environment, environment=environment,

View File

@@ -471,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(

View File

@@ -6,6 +6,7 @@ SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据
from __future__ import annotations from __future__ import annotations
import json
from typing import Iterable from typing import Iterable
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"}) INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
@@ -20,6 +21,103 @@ ACCOUNT_LEASES_TABLE = "account_leases"
DATETIME_UNIX_SECONDS = "datetime_unix_seconds" DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) 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 = ( USER_BUSINESS_TABLES = (
PLATFORMS_TABLE, PLATFORMS_TABLE,
ACCOUNTS_TABLE, ACCOUNTS_TABLE,
@@ -101,13 +199,28 @@ PLATFORMS_TABLE_METADATA = {
PLATFORMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( PLATFORMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("platform_key", "平台键", description="平台唯一标识(英文 kebab-case)", editable=0), _col("platform_key", "平台键", description="平台唯一标识(英文 kebab-case)", editable=0),
_col("display_name", "展示名称", description="平台在界面上的显示名称"), _col("display_name", "展示名称", description="平台在界面上的显示名称"),
_col("domain", "业务域", description="logistics / llm / content / dev / sim 等"), _col(
"domain",
"业务域",
description="物流 / 大模型 / 内容等",
options_json=DOMAIN_OPTIONS_JSON,
),
_col("provider_code", "供应商代码", description="供应商或平台代码,可空"), _col("provider_code", "供应商代码", description="供应商或平台代码,可空"),
_col("default_url", "默认链接", description="平台默认入口 URL"), _col("default_url", "默认链接", description="平台默认入口 URL", display_type="url"),
_col("aliases_json", "别名列表", description="平台别名 JSON 数组", editable=0), _col("aliases_json", "别名列表", description="平台别名 JSON 数组", editable=0),
_col("capabilities_json", "能力声明", description='能力 JSON,如 {"web":true,"api_key":false}', editable=0), _col("capabilities_json", "能力声明", description='能力 JSON,如 {"web":true,"api_key":false}', editable=0),
_col("enabled", "是否启用", description="1=启用,0=停用"), _col(
_col("default_auth_strategy", "默认登录策略", description="新建账号时的默认 auth_strategy"), "enabled",
"是否启用",
description="启用或停用该平台",
options_json=ENABLED_OPTIONS_JSON,
),
_col(
"default_auth_strategy",
"默认登录策略",
description="新建账号时的默认登录方式",
options_json=AUTH_STRATEGY_OPTIONS_JSON,
),
_ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"), _ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
) )
@@ -123,26 +236,73 @@ ACCOUNTS_TABLE_METADATA = {
ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="账号唯一编号", editable=0), _col("id", "编号", description="账号唯一编号", editable=0),
_col("platform_key", "平台", description="所属平台键"), _col(
"platform_key",
"平台",
description="所属平台键(来自平台信息表)",
display_type="lookup",
options_json=PLATFORM_KEY_LOOKUP_JSON,
editable=0,
),
_col("account_label", "账号名称", description="人可读的账号名称"), _col("account_label", "账号名称", description="人可读的账号名称"),
_col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"), _col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"),
_col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"), _col(
_col("tenant_id", "租户编号", description="租户或客户标识,可空"), "login_id_type",
_col("environment", "环境", description="production / simulator / staging 等"), "登录标识类型",
_col("role", "角色", description="booking / tracking / api / default 等"), description="未指定 / 手机号 / 邮箱 / 用户名等",
_col("provider_code", "供应商代码", 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("profile_dir", "浏览器配置目录", description="Playwright 持久化 Profile 路径", editable=0),
_col("url", "入口链接", description="账号默认入口 URL"), _col("url", "入口链接", description="账号默认入口 URL", display_type="url"),
_col("status", "状态", description="active / disabled / needs_review 等"), _col(
_col("session_status", "会话状态", description="unknown / likely_valid / needs_login 等"), "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_used_at", "最后使用时间", "最近一次被业务技能使用的时间"),
_ts("last_login_check_at", "最后登录检查时间", "最近一次登录状态检查时间"), _ts("last_login_check_at", "最后登录检查时间", "最近一次登录状态检查时间"),
_col("last_error_code", "最近错误码", description="最近一次错误代码", editable=0), _col("last_error_code", "最近错误码", description="最近一次错误代码", editable=0),
_col("last_error_message", "最近错误信息", description="最近一次错误说明", editable=0), _col("last_error_message", "最近错误信息", description="最近一次错误说明", editable=0),
_col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0), _col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0),
_col("auth_strategy", "登录方式", description="九种登录策略之一"), _col(
_col("session_persistent", "持久会话", description="是否需要持久化浏览器会话(0/1)"), "auth_strategy",
_col("device_fingerprint", "设备指纹", description="device_bound 策略专用,可空"), "登录方式",
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("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"),
_ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"),
) )
@@ -158,8 +318,20 @@ CREDENTIALS_TABLE_METADATA = {
CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="凭据唯一编号", editable=0), _col("id", "编号", description="凭据唯一编号", editable=0),
_col("account_id", "账号编号", description="关联的 accounts.id,可空"), _col(
_col("platform_key", "平台", description="所属平台键"), "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_label", "凭据名称", description="人可读的凭据名称"),
_col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"), _col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"),
_col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0), _col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0),

View File

@@ -36,6 +36,7 @@ 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.profile_shortcut import create_profile_browser_shortcut
@@ -63,6 +64,7 @@ def create_web_account_record(
) -> Optional[dict]: ) -> Optional[dict]:
"""Create a web/RPA account. On error prints JSON and returns None; on success returns payload (no stdout).""" """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)
@@ -162,7 +164,9 @@ def create_web_account_record(
finally: finally:
conn.close() conn.close()
shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log) 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)
ok_payload = { ok_payload = {

View File

@@ -15,7 +15,7 @@ def cmd_account_ensure_web(
platform_input: str, platform_input: str,
*, *,
login_id: Optional[str] = None, login_id: Optional[str] = None,
login_id_type: str = "unknown", login_id_type: Optional[str] = None,
label: Optional[str] = None, label: Optional[str] = None,
tenant_id: Optional[str] = None, tenant_id: Optional[str] = None,
environment: Optional[str] = None, environment: Optional[str] = None,

View 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"),
},
"已保存",
))

View File

@@ -16,6 +16,7 @@ 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_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,
@@ -39,13 +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_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",
@@ -60,6 +68,7 @@ __all__ = [
"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",

View File

@@ -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:
@@ -83,6 +75,35 @@ def resolve_chromium_channel():
return "chrome" 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/")
@@ -114,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()
@@ -127,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:
@@ -155,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

View File

@@ -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()

View File

@@ -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:

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

View File

@@ -9,7 +9,10 @@ import subprocess
import sys import sys
from typing import Optional from typing import Optional
from service.browser_service import resolve_chromium_executable from service.browser_service import (
human_chrome_shortcut_arguments,
resolve_chromium_executable,
)
_WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
@@ -28,9 +31,10 @@ def create_profile_browser_shortcut(
profile_dir: str, profile_dir: str,
account_label: str, account_label: str,
log: Optional[logging.Logger] = None, log: Optional[logging.Logger] = None,
url: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
在 profile_dir 内创建 {account_label}.lnk,目标为系统 Chrome/Edge + --user-data-dir。 在 profile_dir 内创建 {account_label}.lnk,与「打开浏览器」同一套人用参数。
非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。 非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。
成功返回 .lnk 绝对路径。 成功返回 .lnk 绝对路径。
@@ -52,7 +56,7 @@ def create_profile_browser_shortcut(
lnk_name = f"{_lnk_safe_filename(account_label)}.lnk" lnk_name = f"{_lnk_safe_filename(account_label)}.lnk"
lnk_path = os.path.join(profile_abs, lnk_name) lnk_path = os.path.join(profile_abs, lnk_name)
arguments = f'--user-data-dir="{profile_abs}"' arguments = human_chrome_shortcut_arguments(profile_abs, url)
working_dir = os.path.dirname(browser_exe) working_dir = os.path.dirname(browser_exe)
ps = ( ps = (

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

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

View File

@@ -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:

View File

@@ -102,6 +102,44 @@ def test_sensitive_credential_fields_not_editable(metadata_db):
assert int(searchable) == 0, column_name 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): def test_lease_token_not_editable(metadata_db):
cur = metadata_db.cursor() cur = metadata_db.cursor()
cur.execute( cur.execute(
@@ -144,6 +182,62 @@ def test_profile_dir_not_editable(metadata_db):
assert int(cur.fetchone()[0]) == 0 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): def test_timestamp_columns_use_datetime_display_type(metadata_db):
cur = metadata_db.cursor() cur = metadata_db.cursor()
for table_name in USER_BUSINESS_TABLES: for table_name in USER_BUSINESS_TABLES:

View File

@@ -31,6 +31,33 @@ class TestCreateProfileBrowserShortcut:
out = create_profile_browser_shortcut(str(tmp_path), "demo") out = create_profile_browser_shortcut(str(tmp_path), "demo")
assert out is None 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") @pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None: def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None:
from service.browser_service import resolve_chromium_executable from service.browser_service import resolve_chromium_executable

View 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

View 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()

View File

@@ -23,15 +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", "ensure-web",
"get-credential", "get-credential",
"pick-web", "pick-web",
"resolve", "resolve",
"reveal-profile",
): ):
assert cmd in usage, f"USAGE 缺少 {cmd}" assert cmd in usage, f"USAGE 缺少 {cmd}"
assert "--ensure" in usage assert "--ensure" in usage
assert "--login-id" 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()