Compare commits

...

9 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
25 changed files with 1362 additions and 171 deletions

View File

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

View File

@@ -1,6 +1,6 @@
# assets
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开文件位置 / 打开浏览器)。
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开目录 / 打开浏览器)。
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。

View File

@@ -2,12 +2,159 @@
"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": "打开文件位置",
"label": "打开目录",
"description": "在系统文件管理器中打开该账号的浏览器 Profile 目录。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
@@ -33,12 +180,10 @@
{
"id": "open-browser",
"label": "打开浏览器",
"description": "用该账号持久化 Profile 打开 Chrome/Edge,仅供肉眼核对(不做登录判定)。",
"description": "用该账号持久化 Profile 直接打开系统 Chrome/Edge,仅供肉眼核对(不做登录判定)。与打开目录同为一点即开。",
"placements": ["row"],
"executionProfile": "async",
"confirmation": {
"message": "将用该账号的浏览器 Profile 打开 Chrome/Edge,仅供查看,不会自动登录或改库。"
},
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }

View File

@@ -43,7 +43,9 @@
| 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 get <id> [--with-credentials]` | 输出账号 JSON(若带 `--with-credentials` 则附加关联凭据摘要) |
| `account resolve [--id <id>] [--platform <p>] [--login-id <手机号/用户名>] [--label <备注>] [--query <…>]` | **用人话定位一条账号**(成功输出同 `get`);多条匹配返回 `ERROR:AMBIGUOUS_ACCOUNT` |
@@ -54,7 +56,7 @@
| `account mark-session <id> --session-status <status>` | 标记会话状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile(仅查看) |
| `account open <id>` | 本进程直接打开系统 Chrome/Edge(独立 profile;最大化;跳过首次欢迎页;打开平台 URL) |
| `account reveal-profile <id>` | 在系统文件管理器中打开该账号 `profile_dir` |
### D. 加密备份与迁移(v2)
@@ -75,8 +77,8 @@
| `lease release <lease_token>` | 释放租约 |
| `lease list [--active]` | 列出租约 |
| `lease cleanup` | 清理过期租约 |
| `account open <id>` / `open <id>` | 浏览器查看 |
| `account reveal-profile <id>` | 打开 Profile 目录 |
| `account open <id>` / `open <id>` | 打开浏览器 |
| `account reveal-profile <id>` | 打开目录 |
### 兼容入口(旧版别名)
@@ -109,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` 示例)
```bash

View File

@@ -37,6 +37,8 @@
| 导入加密凭据 | `account import-credentials [--replace-all]` | **通常为空**(计数在 stderr) |
| 初始化 master key | `account init-master-key [--print\|--from-env\|--rotate]` | **单行 JSON** |
| 新增网页账号 | `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": {...}}` |
| 获取 API Key | `credential pick ... [--reveal]` | `{"success": true, ...}` |
| 兼容入口 | `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:
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|reveal-profile
account add-web|copy|update|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open|reveal-profile
init-master-key|export-credentials|import-credentials
credential pick|resolve
lease release|list|cleanup
@@ -23,7 +23,9 @@ from service.account_crypto_commands import (
from service.account_service import (
cmd_account_add_secret,
cmd_account_add_web,
cmd_account_copy,
cmd_account_ensure_web,
cmd_account_update,
cmd_account_get,
cmd_account_get_credential,
cmd_account_list,
@@ -71,11 +73,14 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
[--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>]
[--role <role>] [--provider-code <code>] [--url <url>]
[--extra-json <json>] [--profile-dir <path>]
[--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>
--secret-storage env|windows_credential|none|local_encrypted
[--secret-ref <env_var>] [--secret-stdin]
@@ -276,6 +281,13 @@ def main() -> None:
sub = av[2]
if sub == "add-web":
_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":
@@ -467,7 +479,7 @@ def main() -> None:
def _cmd_add_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", "unknown")
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", "production")
@@ -511,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:
aid_raw = argv[3]
try:
@@ -619,7 +659,7 @@ def _cmd_pick_web_new(argv: list[str]) -> None:
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", "unknown")
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")
@@ -651,7 +691,7 @@ def _cmd_ensure_web(argv: list[str]) -> None:
cmd_account_ensure_web(
platform_input=plat,
login_id=login_id,
login_id_type=login_id_type or "unknown",
login_id_type=login_id_type,
label=label,
tenant_id=tenant_id,
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:
cur = conn.cursor()
cur.execute(

View File

@@ -6,6 +6,7 @@ SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据
from __future__ import annotations
import json
from typing import Iterable
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
@@ -20,6 +21,103 @@ 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,
@@ -101,13 +199,28 @@ PLATFORMS_TABLE_METADATA = {
PLATFORMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("platform_key", "平台键", description="平台唯一标识(英文 kebab-case)", editable=0),
_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("default_url", "默认链接", description="平台默认入口 URL"),
_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="1=启用,0=停用"),
_col("default_auth_strategy", "默认登录策略", description="新建账号时的默认 auth_strategy"),
_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 秒级时间戳)"),
)
@@ -123,26 +236,73 @@ ACCOUNTS_TABLE_METADATA = {
ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_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("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"),
_col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"),
_col("tenant_id", "租户编号", description="租户或客户标识,可空"),
_col("environment", "环境", description="production / simulator / staging 等"),
_col("role", "角色", description="booking / tracking / api / default 等"),
_col("provider_code", "供应商代码", 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"),
_col("status", "状态", description="active / disabled / needs_review 等"),
_col("session_status", "会话状态", description="unknown / likely_valid / needs_login 等"),
_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="九种登录策略之一"),
_col("session_persistent", "持久会话", description="是否需要持久化浏览器会话(0/1)"),
_col("device_fingerprint", "设备指纹", description="device_bound 策略专用,可空"),
_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 秒级时间戳)"),
)
@@ -158,8 +318,20 @@ CREDENTIALS_TABLE_METADATA = {
CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="凭据唯一编号", editable=0),
_col("account_id", "账号编号", description="关联的 accounts.id,可空"),
_col("platform_key", "平台", description="所属平台键"),
_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),

View File

@@ -36,6 +36,7 @@ from service.secret_store import (
make_windows_credential_target,
write_secret,
)
from util.account_identity import 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
@@ -63,6 +64,7 @@ def create_web_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()
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",
platform_input, login_id_type, environment, role)
@@ -162,7 +164,9 @@ def create_web_account_record(
finally:
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)
ok_payload = {

View File

@@ -15,7 +15,7 @@ def cmd_account_ensure_web(
platform_input: str,
*,
login_id: Optional[str] = None,
login_id_type: str = "unknown",
login_id_type: Optional[str] = None,
label: Optional[str] = None,
tenant_id: 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.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 (
cmd_account_mark_error,
cmd_account_mark_session,
@@ -50,7 +51,9 @@ __all__ = [
"PlatformCallerMeta",
"cmd_account_add_secret",
"cmd_account_add_web",
"cmd_account_copy",
"cmd_account_ensure_web",
"cmd_account_update",
"cmd_account_get",
"cmd_account_get_credential",
"cmd_account_list",

View File

@@ -2,7 +2,7 @@
"""
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 check login status.
Does NOT write secret to SQLite.
@@ -11,20 +11,12 @@ import json
import os
import subprocess
import sys
import tempfile
from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import (
get_skill_logger,
subprocess_env_with_trace,
)
from util.logging_config import get_skill_logger
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):
for p in candidates:
@@ -83,6 +75,35 @@ def resolve_chromium_channel():
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():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
print(" • Chrome: https://www.google.com/chrome/")
@@ -155,6 +176,10 @@ def cmd_reveal_profile(account_id):
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,
@@ -168,8 +193,9 @@ def cmd_reveal_profile(account_id):
def cmd_open(account_id):
"""
打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。
打开该账号的持久化浏览器,仅用于肉眼核对 / 手工登录 / 抓 DOM。
与 reveal-profile 同模式:本进程直接拉起系统 Chrome/Edge 后立即返回。
最大化、跳过 Chrome 首次欢迎页;不写数据库、不判定是否已登录、不起 Playwright。
"""
log = get_skill_logger()
init_db()
@@ -179,27 +205,25 @@ def cmd_open(account_id):
if not target:
log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
sys.exit(1)
channel = resolve_chromium_channel()
if not channel:
_print_browser_install_hint()
return
try:
import playwright # noqa: F401
except ImportError:
print(
"ERROR:需要 playwright Python 包。"
)
return
browser_exe = resolve_chromium_executable()
if not browser_exe:
channel = resolve_chromium_channel()
if sys.platform == "win32" or not channel:
_print_browser_install_hint()
sys.exit(1)
# 非 Windows:退回 PATH 上的常见命令名
browser_exe = "google-chrome" if channel == "chrome" else "microsoft-edge"
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
return
os.makedirs(profile_dir, exist_ok=True)
sys.exit(1)
profile_abs = os.path.abspath(profile_dir)
os.makedirs(profile_abs, exist_ok=True)
conn = get_conn()
try:
@@ -207,37 +231,66 @@ def cmd_open(account_id):
finally:
conn.close()
# 与 profile_shortcut 同一套人用参数:独立 user-data-dir 保存登录态
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)]
log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
profile_dir,
"browser_open_launch exe=%s profile_dir=%s url=%s platform=%s",
browser_exe,
profile_abs,
url,
channel,
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:
subprocess.run(
[sys.executable, "-m", "service.open_child_runner", cfg_path],
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
# GUI 浏览器:不挂控制台黑框;不等待关闭(与 reveal 一致立刻返回)
popen_kwargs: dict = {
"stdin": subprocess.DEVNULL,
"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

@@ -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
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]')
@@ -28,9 +31,10 @@ 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,目标为系统 Chrome/Edge + --user-data-dir。
在 profile_dir 内创建 {account_label}.lnk,与「打开浏览器」同一套人用参数。
非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。
成功返回 .lnk 绝对路径。
@@ -52,7 +56,7 @@ def create_profile_browser_shortcut(
lnk_name = f"{_lnk_safe_filename(account_label)}.lnk"
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)
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).
"""
import json
from unittest import mock
import pytest
@@ -14,24 +17,100 @@ class TestPlaywrightLaunchHelpers:
args, _ignore = persistent_context_launch_parts()
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(
os.path.dirname(__file__),
"..",
"scripts",
"service",
"open_child_runner.py",
class TestCmdOpenDirectBrowser:
"""account open = same-process Chrome/Edge launch (like reveal-profile)."""
def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
from db.accounts_repo import insert_account
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)
with open(path, encoding="utf-8") as f:
src = f.read()
assert "launch_persistent_context" in src
assert "headless=False" in src
assert "no_viewport=True" in src
assert '"channel"' in src or "'channel'" in src
clean_db.commit()
fake_exe = str(tmp_path / "chrome.exe")
monkeypatch.setattr(
browser_service,
"resolve_chromium_executable",
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:

View File

@@ -102,6 +102,44 @@ def test_sensitive_credential_fields_not_editable(metadata_db):
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(
@@ -144,6 +182,62 @@ def test_profile_dir_not_editable(metadata_db):
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:

View File

@@ -31,6 +31,33 @@ class TestCreateProfileBrowserShortcut:
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

View File

@@ -54,6 +54,9 @@ def test_reveal_profile_opens_dir(clean_db, tmp_path, capsys):
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

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

@@ -27,6 +27,8 @@ def test_usage_includes_all_account_subcommands():
"init-master-key",
"export-credentials",
"import-credentials",
"copy",
"update",
"add-web",
"ensure-web",
"get-credential",