Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
2026-05-05 18:51:00 +08:00
parent 6b6d2969c3
commit 91057fa3b7
29 changed files with 4582 additions and 900 deletions

View File

@@ -1,42 +1,45 @@
---
name: 账号管理
description: "多平台账号搜狐号、头条、知乎、微信公众号、豆包、Kimi、DeepSeek 等)的本地登记与查询。当用户或编排要添加/删除账号、列出全部或某平台账号、按 id 查看单条账号信息、为网页自动化批量取账号 JSON 或从中选一条候选、在浏览器中打开已登记账号以登录或核对页面时,加载本技能。本技能不在库中记录「是否已登录」;具体子命令与参数以 references/CLI.md 为准。"
version: 1.0.55
name: 账号与凭据管理
description: "通用 Credential & Browser Profile Manager管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流平台Maersk、COSCO、MSC 等、LLM 平台DeepSeek、豆包等、内容平台。业务 skill 通过 CLI 获取账号,不直接读 SQLite。"
version: 1.0.56
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: account-manager
emoji: "👤"
emoji: "🔐"
category: "通用"
allowed-tools:
- bash
---
# 账号管理account-manager
# 账号与凭据管理account-manager
在本地 SQLite 登记各平台账号与持久化用户目录路径,供其它技能通过子进程调用 `scripts/main.py` 读取或维护。**不在库中持久化「是否已登录」**`pick-web` 仅按规则选一条候选,**不表示**当前浏览器已登录
通用 Credential & Browser Profile Manager。在本地 SQLite 管理平台注册、账号身份、凭据元数据API Key/Token 不存明文、RPA 并发锁lease,供其它技能通过 CLI 调用 `scripts/main.py` 获取账号信息
## 核心原则
1. **account-manager 是唯一账号/凭据管理入口** — 业务 skill 不能自己存账号、密码、API Key、Token。
2. **SQLite 只保存元数据**`secret_ref``secret_mask`,不保存原始 secret 明文。
3. **浏览器登录态** — 使用独立 `profile_dir` 保存 cookie/session不保存网页登录密码。
4. **业务 skill 只通过 CLI/API 拿账号** — 不直接读 SQLite。
## 何时使用本技能
出现下列**业务场景**时加载;真正执行时严格按 **`references/CLI.md`** 拼 argv勿臆造参数。子命令与源码函数的对应关系见该文件篇首映射表
1. **登记新账号**用户给出平台名与大陆 11 位手机号 —— `add`
2. **人读查看列表**:要看全部或某一平台的账号台账(多行文本);首参若能识别为平台,可用「只写平台名」或「平台名 + list」的简写 —— `list`(路由见 `cli/app.py`
3. **按 id 取一条机器可读记录**:编排或调试需要单行 JSON —— `get`
4. **按平台或全部拉列表机器可读**stdout 一行 JSON 数组,元素同 `get`,供跨技能编排 —— `list-json`
5. **为某平台挑一条网页自动化用号**stdout 一行 JSON按更新时间等规则选一条 —— `pick-web`
6. **在浏览器里打开已登记账号**:用户说「打开某平台这个账号」「用浏览器打开 id 为 x 的号」等 —— `open`(须已知账号 id本机 Chrome/Edge + Playwright channel不写库、不做登录检测
7. **删除账号**:按 id 删一条、按平台清空、或按平台+手机号删一条 —— `delete``id` / `platform` / `platform`+手机号 三种形式)。
1. **登记网页/RPA 账号**`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir
2. **登记 API Key/Token 账号**`account add-secret` — 平台 + 凭据类型 + secret 存储方式。
3. **查看/列出账号**`account get/list` — JSON 输出
4. **为 RPA 挑选账号**`account pick-web` — 按环境/租户/角色筛选,可带 lease
5. **获取 API Key**`credential pick` — 默认隐藏 secret`--reveal` 时才返回
6. **浏览器打开查看**`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做登录检测
7. **管理 lease**`lease release/list/cleanup` — 防止 RPA 并发
## CLI 调用
语法、默认值、错误约定、**CLI → service** 映射**`references/CLI.md`**。
语法、默认值、错误约定见 **`references/CLI.md`**。
**重要约束**CLI 由宿主或工具执行;**不要把命令原文贴给最终用户看**。添加账号时用户只需在对话中提供**平台名与 11 位完整手机号****不要**引导具体宿主菜单路径;**不要**让用户填写 `profile_dir``add` 时由实现分配路径)。
## 支持的平台
## 平台名称与别名
入库为英文 `platform` 键;用户话术可为中文名或别名。见 `references/PLATFORMS.md`;错误前缀见 `references/ERRORS.md`
`references/PLATFORMS.md`支持物流、LLM、内容三大业务域。
## 数据与环境
@@ -44,14 +47,12 @@ allowed-tools:
## 跨技能集成
`get` / `list-json` / `pick-web` 的 stdout 协议见 `references/INTEGRATION.md`
`account get/list/pick-web` / `credential pick` 的 stdout 协议见 `references/INTEGRATION.md`
## 依赖与运行时
## Secret 存储
- **Python**:共享宿主运行时;勿在技能目录内自建 `.venv`
- **浏览器**`open` 依赖本机 **Chrome 或 Edge****playwright**channel 模式)。详见 `references/RUNTIME.md`
- **环境变量**`JIANGCHANG_*` / `CLAW_*` 等与兄弟技能一致。
- `none`:无 secret浏览器 profile 登录态)
- `env`SQLite 保存环境变量名,运行时从 `os.environ` 读取
- `windows_credential`:正式 Windows 部署,真实 secret 存 Windows Credential Manager
## 资产与示例
`assets/README.md``assets/examples/``assets/schemas/`
详见 `references/RUNTIME.md`

View File

@@ -1,4 +1,6 @@
# 示例 JSON
- `get-response.json``python main.py get <id>` 成功时 **stdout 单行** 对象的形状参考(示例路径为虚构)。
- `list-json-array.json``list-json` 成功时 **stdout 单行** 数组的形状参考(本文件为多行排版便于阅读)。
- `get-response.json``account get <id>` 成功时 stdout 单行对象的形状参考(v2 schema展示为完整 JSON 对象)。
- `list-json-array.json``account list``list-json` 成功时 stdout 单行数组的形状参考(多行排版便于阅读)。
注意:实际输出为 **`{"success":true,"data":...}`** 包裹的格式get/list 系列或直接输出数组list-json 兼容入口)。

View File

@@ -1,10 +1,20 @@
{
"success": true,
"data": {
"id": 1,
"name": "搜狐1号",
"platform": "sohu",
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
}
}

View File

@@ -1,22 +1,36 @@
[
{
"id": 2,
"name": "知乎1号",
"platform": "zhihu",
"phone": "13900001111",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/知乎/13900001111",
"url": "https://www.zhihu.com",
"created_at": "2026-04-02T09:00:00",
"updated_at": "2026-04-02T09:00:00"
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
},
{
"id": 1,
"name": "搜狐1号",
"platform": "sohu",
"phone": "13800138000",
"profile_dir": "D:/jiangchang-data/_anon/account-manager/profiles/搜狐号/13800138000",
"url": "https://mp.sohu.com",
"created_at": "2026-03-01T10:00:00",
"updated_at": "2026-04-01T12:00:00"
"id": 2,
"platform_key": "deepseek",
"account_label": "DeepSeek production key",
"login_id": "",
"login_id_type": "unknown",
"tenant_id": "",
"environment": "production",
"role": "api",
"provider_code": "deepseek",
"profile_dir": "",
"url": "https://chat.deepseek.com",
"status": "active",
"session_status": "unknown",
"created_at": "2026-05-01T10:00:00",
"updated_at": "2026-05-01T10:00:00"
}
]

View File

@@ -2,18 +2,47 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/account-manager/account-record.schema.json",
"title": "AccountRecord",
"description": "account-manager get / list-json / pick-web 返回的单条账号对象(核心字段extra_json 合并键不枚举",
"description": "account-manager account get / pick-web / list 返回的单条账号对象(v2 schema",
"type": "object",
"required": ["id", "platform_key", "account_label", "environment", "role", "status", "session_status"],
"properties": {
"id": { "type": "integer", "description": "账号主键" },
"platform_key": { "type": "string", "description": "平台唯一键,如 maersk、deepseek" },
"account_label": { "type": "string", "description": "人可读名称" },
"login_id": { "type": "string", "description": "登录标识email/phone/username/customer_code" },
"login_id_type": { "type": "string", "enum": ["email", "username", "phone", "customer_code", "api_account", "unknown"] },
"tenant_id": { "type": "string", "description": "租户/项目标识" },
"environment": { "type": "string", "enum": ["simulator", "staging", "production", "test"] },
"role": { "type": "string", "description": "booking / tracking / admin / sales / api / default" },
"provider_code": { "type": "string", "description": "供应商代码" },
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
"url": { "type": "string", "description": "账号默认入口 URL" },
"status": { "type": "string", "enum": ["active", "disabled", "needs_review"] },
"session_status": { "type": "string", "enum": ["unknown", "likely_valid", "needs_login", "expired"] },
"last_used_at": { "type": ["string", "null"], "description": "最近使用时间 ISO8601" },
"last_login_check_at": { "type": ["string", "null"] },
"last_error_code": { "type": "string" },
"last_error_message": { "type": "string" },
"created_at": { "type": ["string", "null"], "description": "创建时间 ISO8601" },
"updated_at": { "type": ["string", "null"], "description": "更新时间 ISO8601" },
"credentials": {
"type": "array",
"description": "关联凭据(仅 account get --with-credentials 时返回)",
"items": {
"type": "object",
"required": ["id", "name", "platform", "phone", "profile_dir", "url", "created_at", "updated_at"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"platform": { "type": "string", "description": "内部英文键,如 sohu、zhihu" },
"phone": { "type": "string", "description": "归一后的数字串,可能为空字符串" },
"profile_dir": { "type": "string", "description": "Playwright 用户数据目录绝对路径" },
"url": { "type": "string", "format": "uri" },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
"credential_label": { "type": "string" },
"credential_type": { "type": "string" },
"secret_storage": { "type": "string" },
"secret_ref": { "type": "string" },
"secret_mask": { "type": "string" },
"status": { "type": "string" }
}
}
},
"lease_token": { "type": "string", "description": "仅 pick-web --lease 时返回" },
"lease_expires_at": { "type": "string", "description": "仅 pick-web --lease 时返回" }
},
"additionalProperties": true
}

View File

@@ -4,102 +4,157 @@
## 子命令与实现(与代码一致)
| CLI`main.py` 第一参或等价路由) | 实现(`scripts/service/` |
|----------------------------------|---------------------------|
| `add` | `account_service.cmd_add` |
| `list`;或首参为平台且 `list`/单参 | `account_service.cmd_list`(路由见 `cli/app.py` |
| `get` | `account_service.cmd_get` |
| `list-json` | `account_service.cmd_list_json` |
| `pick-web` | `account_service.cmd_pick_web` |
| `open` | `browser_service.cmd_open` |
| `delete` | `account_service.cmd_delete_by_id` / `cmd_delete_by_platform` / `cmd_delete_by_platform_phone` |
### 平台管理
入口:`scripts/main.py``cli/app.py` → 上表。
| CLI | 说明 |
|-----|------|
| `platform list [--domain logistics\|llm\|content]` | 列出平台注册 |
| `platform get <platform>` | 获取平台详情 |
### 账号管理
| CLI | 说明 |
|-----|------|
| `account add-web --platform <p> --login-id <id> [...]` | 创建网页/RPA 账号 |
| `account add-secret --platform <p> --credential-type <type> [...]` | 创建 API Key/Token 凭据 |
| `account get <id> [--with-credentials]` | 获取单条账号 JSON |
| `account list [--platform <p>] [--environment <env>] [--tenant-id <t>] [--role <role>]` | 列出账号 JSON 数组 |
| `account pick-web --platform <p> [--lease] [--ttl-sec 900]` | 挑选网页自动化候选 |
| `account mark-session <id> --session-status <status>` | 标记登录状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile仅查看 |
### 凭据管理
| CLI | 说明 |
|-----|------|
| `credential pick --platform <p> [--type <type>] [--reveal]` | 挑选凭据(默认隐藏 secret |
| `credential resolve --id <credential_id> [--reveal]` | 按 ID 解析凭据 |
### 租约管理
| CLI | 说明 |
|-----|------|
| `lease release <lease_token>` | 释放租约 |
| `lease list [--active]` | 列出租约 |
| `lease cleanup` | 清理过期租约 |
### 浏览器
| CLI | 说明 |
|-----|------|
| `account open <id>` | 打开浏览器查看(仅查看) |
| `open <id>` | 同上(兼容别名) |
### 兼容入口(旧版别名)
| CLI | 等价于 |
|-----|--------|
| `get <id>` | `account get <id>` |
| `list-json <platform\|all> [--limit N]` | JSON 数组输出 |
| `pick-web <platform>` | `account pick-web --platform <platform>` |
| `list [platform\|all]` | 人类可读列表 |
| `add <platform> <phone>` | `account add-web`phone 作为 login_id |
| `delete id\|platform <...>` | 删除账号 |
---
## account add-web
```bash
python main.py account add-web \
--platform maersk \
--login-id demo@maersk.com \
--login-id-type email \
--label "Maersk simulator booking" \
--tenant-id tenant-demo \
--environment simulator \
--role booking \
--provider-code maersk \
--url http://localhost:5180/industries/logistics/maersk/login
```
- `--platform`(必填):平台键/别名
- `--login-id`登录标识email/username/phone/customer_code
- `--login-id-type`:默认 `unknown`
- `--label`:人可读名称,不填则自动拼接
- `--tenant-id`:租户/项目标识
- `--environment`:默认 `production`
- `--role`:默认 `default`
- `--provider-code`:默认取平台 provider_code
- `--url`:默认取平台 default_url
- `--extra-json`:扩展 JSON
- `--profile-dir`:高级调试用,不推荐
输出 JSON包含 `id``profile_dir``url` 等。
## account add-secret
```bash
# env 方式
python main.py account add-secret \
--platform deepseek \
--credential-type api_key \
--label "DeepSeek env key" \
--secret-storage env \
--secret-ref DEEPSEEK_API_KEY \
--environment production \
--role api
# windows_credential 方式
echo "sk-test-1234" | python main.py account add-secret \
--platform deepseek \
--credential-type api_key \
--label "DeepSeek production key" \
--secret-storage windows_credential \
--secret-stdin \
--environment production \
--role api
```
- `--secret-storage``env` / `windows_credential` / `none`
- `--secret-stdin`:从 stdin 读取 secret仅 windows_credential
- `--secret-ref`:环境变量名(仅 env
- `none` 仅允许 `credential_type=browser_profile``note`
输出 JSON包含 `account_id`(未指定 `--account-id` 时会自动创建一条用于挂载凭据的账号记录)、`credential_id``secret_mask``secret_ref`**不包含 secret 明文**。
## account pick-web
```bash
python main.py account pick-web \
--platform maersk \
--environment simulator \
--tenant-id tenant-demo \
--role booking \
--lease \
--ttl-sec 900 \
--holder monitor-competitor-rates \
--purpose maersk_sim_rpa
```
- 筛选 status=active 且未被 active lease 占用的账号
- 优先 session_status != expired
- 优先 last_used_at 为空的账号
- `--lease`:创建 lease返回 `lease_token``lease_expires_at`
- `--ttl-sec`:租约时长,默认 900 秒
## credential pick
成功时输出单行 JSON`{"success": true, ...fields }`;失败时为 [ERRORS.md](ERRORS.md) 的机器 JSON。
```bash
# 默认不返回 secret
python main.py credential pick --platform deepseek --type api_key --environment production --role api
# --reveal 才返回 secret 明文
python main.py credential pick --platform deepseek --type api_key --environment production --role api --reveal
```
## 通用说明
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**中文展示名、别名或英文键**(如 `sohu``搜狐号`)。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile 目录由环境变量决定,网关与本地终端不一致时会「看不到同一份数据」。详见 [RUNTIME.md](RUNTIME.md)。
- **机器可读命令**`get``list-json``pick-web` 的成功/失败约定见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码前缀**:见 [ERRORS.md](ERRORS.md)。
> 说明:当前版本 **未实现** `health` / `version` 子命令;与工作区其它技能的统一约定可对齐后另补。
---
## 子命令一览
### `add` — 添加账号
```bash
python main.py add <平台> <手机号>
```
- 手机号为**中国大陆 11 位**`1` 开头,第二位 `39`),可含空格/分隔符,入库前会归一成数字。
- 同一 `platform` 下同一手机号不可重复。
### `list` — 列出账号(人类可读多行块)
```bash
python main.py list [平台|all|全部]
```
- 省略第二参数时等价于 `all`
- 默认最多 **10** 条(按创建时间倒序);内部实现见 `service/account_service.cmd_list`
### 简写:`python main.py <平台>` / `python main.py <平台> list`
- 若第一个参数能解析为平台,则等价于 `list <平台>`
### `get` — 按 id 输出**单行 JSON**
```bash
python main.py get <id>
```
- 成功:`stdout` **仅一行** JSON 对象UTF-8
- 失败:首行 `ERROR:ACCOUNT_NOT_FOUND`,路径提示在 stderr。
### `list-json` — 列表的**单行 JSON 数组**(跨技能)
```bash
python main.py list-json <平台|all|全部> [--limit N]
```
- 成功:`stdout` **仅一行** JSON 数组;元素结构与 `get` 单条一致。
- 默认 `limit=200`,实际 clamp 为 `1..500`
- 排序:按 `updated_at` 倒序。
### `pick-web` — 网页自动化候选账号(单行 JSON
```bash
python main.py pick-web <平台>
```
- 按该平台 `updated_at``created_at` 倒序选一条;**不**表示当前浏览器已登录。
- 失败:首行 `ERROR:`
### `open` — 仅打开浏览器(**不写库、不检测登录**
```bash
python main.py open <id>
```
- 需本机 Chrome/Edge + Playwright详见运行环境说明。
- 仅供人工核对页面;登录与否由业务侧(如 llm-manager 网页引擎)在使用账号时自行处理。
### `delete` — 删除库记录并尽量删除 profile 目录
```bash
python main.py delete id <id>
python main.py delete platform <平台>
python main.py delete platform <平台> <手机号>
```
- 第二段必须为 `id``platform`(小写不敏感)。
---
## 无参数 / 未知子命令
- 无参数或无法解析:打印用法概览;未知子命令时首行 `ERROR:CLI_UNKNOWN_OR_BAD_ARGS`,退出码 `1`
- **平台参数**:可为 `scripts/util/platforms.py` 中配置的**展示名、别名或英文键**(如 `sohu``搜狐号``maersk``马士基`)。详见 [PLATFORMS.md](PLATFORMS.md)。
- **数据与路径**库文件、profile 目录由环境变量决定。详见 [RUNTIME.md](RUNTIME.md)。
- **机器可读命令**成功时输出单行 JSON失败时首行 `ERROR:`。详见 [INTEGRATION.md](INTEGRATION.md)。
- **错误码**:见 [ERRORS.md](ERRORS.md)。

View File

@@ -1,8 +1,20 @@
# 终端前缀约定account-manager
# 终端错误码account-manager
约定:**机器解析只依赖 `stdout` 首行**时,应先判断首行是否以 `ERROR:` 开头;多行说明可忽略或仅作展示。
## `ERROR:` — CLI 参数 / 路由
## 新增机器 JSON 失败格式
```json
{
"success": false,
"error": {
"code": "ERROR:...",
"message": "..."
}
}
```
## ERROR:CLI — CLI 参数 / 路由
| 前缀 | 含义 |
|------|------|
@@ -13,31 +25,55 @@
| `ERROR:CLI_DELETE_BAD_MODE` | `delete` 第二段不是 `id`/`platform` |
| `ERROR:CLI_LIST_JSON_MISSING_PLATFORM` | `list-json` 缺少平台参数 |
| `ERROR:CLI_PICK_WEB_MISSING_ARGS` | `pick-web` 缺少平台 |
| `ERROR:CLI_BAD_ARGS` | 子命令参数缺失或非法 |
| `ERROR:CLI_UNKNOWN_OR_BAD_ARGS` | 无法识别的子命令或参数组合 |
## `ERROR:`业务与数据
## ERROR: — 数据
| 前缀 | 含义 |
|------|------|
| `ERROR:INVALID_PLATFORM_LIST` | `list` 的平台名无法识别 |
| `ERROR:INVALID_PLATFORM_LIST_JSON` | `list-json` 的平台名无法识别 |
| `ERROR:INVALID_PLATFORM` | 其它子命令中平台无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果(或 `delete platform` 下无记录) |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id/记录不存在 |
| `ERROR:PHONE_REQUIRED` | 缺少手机号 |
| `ERROR:PHONE_INVALID` | 手机号格式不符合大陆 11 位规则 |
| `ERROR:DUPLICATE_PHONE_PLATFORM` | 同平台下山手机号已存在 |
| `ERROR:DELETE_INVALID_ID` | 删除 id 非法 |
| `ERROR:NO_ACCOUNT` | `pick-web` 该平台无任何记录 |
| `ERROR:DB_ERROR` | SQLite 写入失败等 |
## `ERROR:`浏览器 / 环境
## ERROR: — 平台与账号
| 前缀 | 含义 |
|------|------|
| `ERROR:需要 playwright Python 包:…` | 未安装 `playwright` 包,或本机无 Chrome/Edgechannel 模式,**不**需 `playwright install chromium` |
| `ERROR:INVALID_PLATFORM` | 平台名无法识别 |
| `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 |
| `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 |
| `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled |
| `ERROR:NO_ACCOUNT` | `pick-web` 无符合条件的可用账号(含:均被 active 未过期 lease 占用时) |
## ERROR: — 租约
| 前缀 | 含义 |
|------|------|
| `ERROR:LEASE_CONFLICT` | 同账号已有 active 未过期 lease |
| `ERROR:LEASE_NOT_FOUND` | 未找到活跃租约 |
| `ERROR:LEASE_EXPIRED` | 租约已过期 |
## ERROR: — 凭据与 Secret
| 前缀 | 含义 |
|------|------|
| `ERROR:CREDENTIAL_NOT_FOUND` | 凭据不存在 |
| `ERROR:CREDENTIAL_DISABLED` | 凭据不可用 |
| `ERROR:SECRET_STORAGE_UNSUPPORTED` | 不支持的 secret 存储方式 |
| `ERROR:SECRET_REF_MISSING` | env 存储缺少环境变量名 |
| `ERROR:SECRET_READ_FAILED` | 读取 secret 失败 |
| `ERROR:SECRET_WRITE_FAILED` | 写入 secret 失败 |
| `ERROR:SECRET_ENV_MISSING` | 环境变量未设置 |
| `ERROR:WINDOWS_CREDENTIAL_UNAVAILABLE` | 非 Windows 平台不支持 |
## ERROR: — 浏览器 / 环境
| 前缀 | 含义 |
|------|------|
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
## 非前缀输出
- **成功添加账号**、**删除成功**等可能包含 `✅` 等人类可读行,**不宜**作为稳定协议解析
- **`get` / `list-json` / `pick-web` 成功**`stdout` 应为**单行 JSON**失败时首行 `ERROR:`勿当 JSON
- **`get` / `list-json` / `pick-web`** 成功时:`stdout` 应为**单行 JSON**
- 失败时首行 `ERROR:`可能有后续说明行。
- **新增机器命令建议统一 JSON 输出**,旧兼容命令可继续输出 `ERROR:` 文本前缀。

View File

@@ -1,35 +1,93 @@
# 与其它 Skill 集成account-manager
## 核心约束
业务 skill **不能自己存账号、密码、API Key、Token**。必须通过 account-manager CLI 获取。
## 推荐调用方式
使用 **`subprocess`** 调用技能根目录下的 `scripts/main.py`,工作目录可为技能根或 `scripts/`(只要命令中的 `python` 与路径一致)。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致,否则读写到错误库
使用 **`subprocess`** 调用 `scripts/main.py`,工作目录可为技能根或 `scripts/`。宿主注入的 **`JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`** 必须与最终用户会话一致。
## 机器可读命令摘要
| 目的 | 命令 | 成功时 stdout |
|------|------|----------------|
| 取单条账号 JSON | `get <id>` | 单行 JSON 对象 |
| 批量账号 JSON | `list-json <平台或all> [--limit N]` | 单行 JSON 数组 |
| 网页自动化候选账号 | `pick-web <平台>` | 单行 JSON 对象(按 `updated_at` 选一条,**不**表示当前已登录) |
| 取单条账号 JSON | `account get <id>` | 单行 JSON 对象 |
| 批量账号 JSON | `account list --platform <p> [--environment <e>]` | 单行 JSON 数组 |
| 网页自动化候选 | `account pick-web --platform <p> [--lease]` | 单行 JSON 对象 |
| 获取 API Key | `credential pick --platform <p> --type api_key [--reveal]` | 单行 JSON`success: true` + 字段) |
| 兼容入口 | `get <id>` / `list-json <p>` / `pick-web <p>` | 同上 |
## 解析协议(强建议)
## 解析协议
1. 读取子进程 **stdout** 全文或首行。
2. 若首行以 **`ERROR:`** 开头 → 失败,不要 `json.loads` 整段 stdout
3. **`get` / `list-json` / `pick-web`**:成功时应对**第一行**做 `json.loads`;后续行应不存在,若有则视为非协议内容
1. 读取子进程 **stdout** 首行。
2. 若首行以 **`ERROR:`** 开头 → 失败。
3. 否则对首行做 `json.loads`
## JSON 对象字段(与 `get` 一致)
## JSON 对象字段
典型字段见仓库 `assets/examples/get-response.json``assets/schemas/account-record.schema.json`
### account get / pick-web
常见键名包括:`id`, `name`, `platform`, `phone`, `profile_dir`, `url`, `created_at`, `updated_at``extra_json` 列中的扁平扩展可能并入同一对象。
```json
{
"id": 1,
"platform_key": "maersk",
"account_label": "Maersk simulator booking",
"login_id": "demo@maersk.com",
"login_id_type": "email",
"tenant_id": "tenant-demo",
"environment": "simulator",
"role": "booking",
"provider_code": "maersk",
"profile_dir": "D:/claw-data/10032/account-manager/profiles/logistics/maersk/Maersk_simulator_booking_demo@maersk.com",
"url": "http://localhost:5180/industries/logistics/maersk/login",
"status": "active",
"session_status": "unknown",
"last_used_at": "2026-05-01T10:00:00",
"created_at": "2026-05-01T09:00:00",
"updated_at": "2026-05-01T09:00:00"
}
```
**库中不再持久化「是否已登录」**;编排侧在网页自动化中自行处理登录(与 `pick-web` / `get` 返回的 `profile_dir` 同一浏览器会话)。
pick-web 额外字段(带 lease 时):
- `lease_token`: 租约 token
- `lease_expires_at`: 过期时间
## Publisher / 自动化流水线示例
### credential pick
1. `pick-web 搜狐号`(或目标平台)→ 解析 JSON → 取 `id``profile_dir``url`
2. 使用 `profile_dir` 启动持久化浏览器上下文,在业务页面内完成或等待登录。
```json
{
"success": true,
"id": 1,
"platform_key": "deepseek",
"credential_label": "DeepSeek production key",
"credential_type": "api_key",
"secret_storage": "env",
"secret_ref": "DEEPSEEK_API_KEY",
"secret_mask": "****7890",
"status": "active"
}
```
**只有 `--reveal` 时才返回 `secret` 字段。业务 skill 不要向最终用户展示 reveal 输出。**
失败时使用统一 JSON`{"success": false, "error": {"code": "ERROR:...", "message": "..."}}`(见 [ERRORS.md](ERRORS.md))。
## 业务流程示例
### 物流 RPA
1. `account pick-web --platform maersk --environment simulator --role booking --lease --holder my-skill`
2. 解析 JSON → 取 `id``profile_dir``url``lease_token`
3.`profile_dir` 启动持久化浏览器上下文
4. 业务完成后:`lease release <lease_token>`
5. 如遇登录失效:`account mark-session <id> --session-status needs_login`
### LLM API 调用
1. `credential pick --platform deepseek --type api_key --environment production --reveal`
2. 解析 JSON → 取 `secret` 字段
3. 在业务进程内存中使用,不写文件、不打日志
## 退出码

View File

@@ -1,24 +1,50 @@
# 平台与别名account-manager
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于 AI/文档阅读;若与代码不一致,**以代码为准**。
**权威来源**`scripts/util/platforms.py` 中的 `PLATFORMS`。下表便于文档阅读;若与代码不一致,**以代码为准**。
入库字段 `accounts.platform` 使用下表 **英文键**小写。CLI 与用户话术可使用 **展示名****别名**
## 物流平台
| 英文键 (`platform`) | 展示名 (`label`) | 默认账号名前缀 (`prefix`) | 别名 (`aliases`) | 默认 URL |
|---------------------|------------------|---------------------------|------------------|----------|
| `sohu` | 搜狐号 | 搜狐 | 搜狐 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条 | 头条 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | (同 label | 知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 微信 | 公众号、微信 | https://mp.weixin.qq.com |
| `kimi` | Kimi | (同 label | 月之暗面 | https://kimi.moonshot.cn |
| `deepseek` | DeepSeek | (同 label | — | https://chat.deepseek.com |
| `doubao` | 豆包 | (同 label | — | https://www.doubao.com |
| `qianwen` | 通义千问 | 通义 | 通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | 文心一言 | 文心 | 文心、一言 | https://yiyan.baidu.com |
| `yuanbao` | 腾讯元宝 | 元宝 | 元宝 | https://yuanbao.tencent.com |
| `gemini` | Gemini | Gemini | 谷歌Gemini、Google Gemini、google gemini、Bard | https://gemini.google.com |
| 英文键 | 展示名 | 供应商代码 | 别名 |
|--------|--------|-----------|------|
| `maersk` | Maersk | maersk | 马士基 |
| `cosco` | COSCO | cosco | 中远海运 |
| `msc` | MSC | msc | 地中海航运 |
| `cma_cgm` | CMA CGM | cma_cgm | 达飞 |
| `evergreen` | Evergreen | evergreen | 长荣 |
| `cargo_wise` | CargoWise | cargo_wise | Cargo Wise |
| `freightos` | Freightos | freightos | — |
| `webcargo` | WebCargo | webcargo | Web Cargo |
| `sinotrans` | Sinotrans | sinotrans | 中外运 |
## 解析规则(摘要)
## LLM / AI 平台
- 英文键本身、各 `label`、各 `aliases` 条目(及小写形式)均可解析到对应键。
- 无法识别时,相关子命令会输出 `ERROR:INVALID_PLATFORM``ERROR:INVALID_PLATFORM_LIST` / `INVALID_PLATFORM_LIST_JSON` 等,并附带「支持的平台」提示。
| 英文键 | 展示名 | 别名 | 默认 URL |
|--------|--------|------|----------|
| `deepseek` | DeepSeek | — | https://chat.deepseek.com |
| `doubao` | Doubao | 豆包 | https://www.doubao.com |
| `kimi` | Kimi | 月之暗面 | https://kimi.moonshot.cn |
| `qianwen` | Qianwen | 通义千问、通义、千问 | https://tongyi.aliyun.com |
| `yiyan` | YiYan | 文心一言、文心、一言 | https://yiyan.baidu.com |
| `yuanbao` | Yuanbao | 腾讯元宝、元宝 | https://yuanbao.tencent.com |
| `gemini` | Gemini | 谷歌Gemini、Google Gemini、Bard | https://gemini.google.com |
| `minimax` | MiniMax | — | — |
## 内容平台
| 英文键 | 展示名 | 别名 | 默认 URL |
|--------|--------|------|----------|
| `sohu` | 搜狐号 | 搜狐 | https://mp.sohu.com |
| `toutiao` | 头条号 | 头条 | https://mp.toutiao.com/ |
| `zhihu` | 知乎 | 知乎号 | https://www.zhihu.com |
| `wechat` | 微信公众号 | 公众号、微信 | https://mp.weixin.qq.com |
## 解析规则
- 英文键本身、各 `display_name`、各 `aliases` 条目(及小写形式)均可解析到对应键。
- 不区分大小写。
- 无法识别时输出 `ERROR:INVALID_PLATFORM`,并附带支持的平台列表。
- 解析函数:`resolve_platform_key(name)` — 支持 key、display_name、aliases。
## 平台注册到数据库
`seed_platforms(conn)` 在每次 `init_db()` 后自动执行,将 `PLATFORMS` 字典 upsert 到 `platforms` 表。

View File

@@ -1,51 +1,52 @@
---
description: "统一管理搜狐、头条、知乎、微信、豆包、Kimi、DeepSeek 等多平台账号与浏览器配置目录。添加账号后,其它技能(如大模型写作、平台发布)可按平台自动选用对应环境。"
description: "通用 Credential & Browser Profile Manager管理网页账号、RPA 浏览器 profile、LLM/API 平台 API Key。支持物流、LLM、内容三大业务域。"
---
# 👤 账号管理
# 🔐 账号与凭据管理
把你在各平台的账号**登记到本地**,后续「用大模型写稿」「发布到搜狐号」等技能才能按平台选用对应账号
把你在各平台的账号与凭据**登记到本地**,后续物流 RPA、大模型写作、内容发布等技能才能自动选用对应环境
> 本技能**只负责登记账号**,不在此处判断「有没有登录网站」。真正打开网页时,由对应业务(例如大模型网页版)在同窗口里提示你完成登录
> 本技能**唯一**账号/凭据管理入口。业务 skill 不自己存密码、API Key而是通过 CLI 调用获取
## 它能帮你做什么
- **添加账号** —一个平台(如豆包、搜狐号),在对话里说出 **11 位大陆手机号**即可
- **查看列表** —— 按平台或全部列出已登记的账号
- **删除账号** —— 按 id、按平台整批、或按「平台 + 手机号」删除,并可清理对应本地目录
- **给其它技能用** —— 自动化的技能会通过内部接口按平台**取一条候选账号**(不要求你手工抄 id)
- **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录
- **添加 API Key/Token** — 安全存储API Key 明文存入 Windows Credential Manager 或环境变量引用SQLite 只保存元数据和打码值
- **查看/列出账号** — 按平台、环境、角色等多维度筛选
- **给 RPA 技能用** — `pick-web` 按规则挑候选账号,支持 lease 防并发
- **给 LLM 技能用** — `credential pick` 安全获取 API Key。
- **在浏览器里查看** — `open <id>` 打开 Chrome/Edge 查看,仅查看不写库。
## 你可以这样对它说
- 「帮我添加一个豆包账号,手机号 138xxxx。」
-列出我所有的搜狐号账号。」
-删除头条号里手机尾号 3701 的那条。」
-我想看看 3 号账号在浏览器里打开是什么页面。」
- 「帮我添加一个 Maersk 模拟器账号,邮箱 demo@maersk.com。」
-添加 DeepSeek 的 API Key存在 Windows 凭据管理器里。」
-列出所有物流平台的账号。」
-用浏览器打开 3 号账号看看页面。」
- 「给 RPA 技能取一个 maersk 模拟器的 booking 账号。」
## 你需要提供什么
| 场景 | 你需要给的 |
|---|---|
| 添加账号 | **平台名称**(可说中文,如豆包、搜狐号) + **大陆 11 位手机号**(同平台下同号不能重复) |
| 删除 | 账号 **id**,或平台名,或平台名 + 手机号 |
| 仅打开浏览器核对 | 账号 **id**(需本机已安装 Chrome 或 Edge) |
## 失败了怎么办
- **提示手机号格式不对**:需 **1 开头、第二位 39、共 11 位数字**
- **提示同平台手机号已存在**:该平台上这条号已经加过,无需重复添加。
- **列表是空的但你觉得应该有**:可能是当前设备与云端使用了**不同的数据目录**;在宿主设置里确认数据根目录与账号一致,或联系管理员。
| 添加网页账号 | **平台名称** + **登录标识**(邮箱/手机/用户名) |
| 添加 API Key | **平台名称** + **凭据类型**api_key 等)+ **secret**(通过 stdin 或环境变量) |
| 查看/列出 | 可选:平台名、环境、角色等筛选条件 |
| 浏览器打开 | 账号 **id**(需本机已安装 Chrome 或 Edge |
## 它不做什么
- 不在此处替你**判定**各网站「是否已登录」(由使用账号的具体功能负责)
- 不代替**内容管理、发布、大模型调用**等业务技能(本技能只提供账号与目录信息)
- ❌ 不存储你的**大模型 API Key**(那是大模型管理技能的职责)
- 不在此处判定各网站「是否已登录」(由业务技能自行处理)
- 不代替内容管理、发布、大模型调用等业务技能
- 不保存 API Key 明文在 SQLite 中(仅存引用和打码值)
## 相关技能
## Secret 存储方式
- **大模型管理**:写稿时若走网页版大模型,会依赖本技能登记的账号与目录。
- **内容管理、各平台发布**:按平台取账号时也会用到本技能的数据。
| 方式 | 说明 |
|---|---|
| `none` | 无 secret浏览器 profile 登录态) |
| `env` | 引用环境变量名,运行时读取 |
| `windows_credential` | 正式推荐:真实 secret 存 Windows Credential Manager |
---

View File

@@ -1,48 +1,59 @@
# 运行环境与数据路径account-manager
## 数据落盘位置(当前实现)
## 数据落盘位置
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**(与 `jiangchang-platform-kit` 同源)`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。
`get_data_root()` / `get_user_id()` 来自 **`scripts/jiangchang_skill_core/runtime_env.py`**`scripts/util/runtime_paths.py` 在此基础上拼技能子目录。
- **数据根目录**:优先 **`JIANGCHANG_DATA_ROOT`**未设置且未走本地注入时Windows 默认 `D:\jiangchang-data`,其它系统 `~/.jiangchang-data`
- **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置且未走本地注入时为 **`_anon`**。
- **用户/工作空间 id**:优先 **`JIANGCHANG_USER_ID`**;未设置时为 **`_anon`**。
- **技能数据目录**`{数据根}/{用户id}/account-manager/`
- **SQLite**`account-manager.db`
- **Profile**`profiles/<平台展示名>/<手机号>/`(由 `get_default_profile_dir` 生成)
- **日志**:与其它技能共用**用户级**目录 `{数据根}/{用户id}/logs/jiangchang.log`(按日轮转)
- **Profile**`profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix}]/`
- **日志**:与其它技能共用 `{数据根}/{用户id}/logs/jiangchang.log`
## 调试为什么「list 没数据」
- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**,库文件不是同一份
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`**`1`/`true`/`yes`/`on`后,每次 CLI 命令前**stderr** 打印实际根目录、用户 id 与数据库路径(`_maybe_print_paths_debug_cli`
- 若终端与网关注入的 `JIANGCHANG_*` 不一致,会写到**不同用户目录**。
- 设置 **`JIANGCHANG_ACCOUNT_DEBUG_PATHS=1`** 后,每次 CLI 命令前在 **stderr** 打印实际路径
## 本地 CLI 默认值`runtime_env`
## 本地 CLI 默认值
各技能 **`main.py`** 在 `sys.path` 就绪后、import 业务包之前调用 **`apply_cli_local_defaults()`**(实现见 `jiangchang_skill_core.runtime_env`
各技能 `main.py` 调用 `apply_cli_local_defaults()`
- `CLI_LOCAL_DEV_ENABLED = True` 时,未设置环境变量则注入平台默认值。
- 发版前改为 `False` 或依赖宿主注入。
- 代码内 **`CLI_LOCAL_DEV_ENABLED = True`** 时,若当前未设置 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`,会注入平台默认数据根与 **`DEFAULT_LOCAL_USER_ID`**(与 kit 中常量一致)。
- 代码内为 **`False`** 时,可设环境变量 **`JIANGCHANG_CLI_LOCAL_DEV=1`** 达到同样效果。
- **发版/嵌入宿主前**:将各技能 vendored 的 `runtime_env.py``CLI_LOCAL_DEV_ENABLED` 改为 **`False`**,或完全依赖宿主注入。
## Secret 存储
| 方式 | 说明 | 适用场景 |
|------|------|----------|
| `none` | 不存 secret | 浏览器 profile 登录态 |
| `env` | SQLite 只存环境变量名,运行时从 `os.environ` 读取 | 轻量测试 |
| `windows_credential` | 真实 secret 存 Windows Credential Manager | Windows 本地正式部署 |
- **不把 secret 明文写入 SQLite**
- **不在日志中记录 secret 明文**
- 只有 `credential pick --reveal``credential resolve --reveal` 才返回 secret
## 日志
实现见 `scripts/jiangchang_skill_core/unified_logging.py``util/logging_config.py` 仅 re-export。
| 变量 | 作用 |
|------|------|
| `JIANGCHANG_LOG_LEVEL` | 日志级别,默认 `INFO` |
| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 等时WARNING 及以上同步 stderr |
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径(默认 `{数据根}/{用户id}/logs/jiangchang.log` |
| `JIANGCHANG_LOG_FILE` | 覆盖主日志文件绝对路径 |
| `JIANGCHANG_LOG_BACKUP_COUNT` | 轮转保留份数,默认 `30` |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入以串联同一调用链 |
| `JIANGCHANG_TRACE_ID` | 可选;跨技能子进程由父进程注入 |
## 浏览器open
`open``service/browser_service.py` 与子进程 `service/open_child_runner.py` 启动持久化上下文,仅用于人工查看;**不做**登录态 DOM 检测。
- 本机已安装 Chrome 或 Edge
- Python 环境需 `playwright` 包(匠厂共享 runtime 已包含)
- 使用 **channel 模式**`channel=chrome|msedge`**不下载 Chromium**
- **不要执行 `playwright install chromium`**
依赖:**本机已安装 Chrome 或 Edge**Python 环境需 **`playwright` 包**(匠厂共享 `python-runtime` 已包含)。运行时使用 **Playwright `channel=chrome|msedge`** 调系统浏览器,**不要**执行 `playwright install chromium`(避免下载自带 Chromium
### 关键浏览器参数
## 常见问题(简要)
- **`ERROR:需要 playwright Python 包`**:确认共享 venv 已 `uv sync`;本机安装 Chrome 或 Edge。
- **`ERROR:PROFILE_DIR_MISSING`**:库中该账号 `profile_dir` 为空,需检查数据或重新添加账号流程。
- `headless=False`
- `no_viewport=True`
- `args` 包含 `--start-maximized`
- stealth 模式可选(`OPENCLAW_PLAYWRIGHT_STEALTH` 环境变量控制)

View File

@@ -1,28 +1,122 @@
# 数据存储(account-manager)
# 数据库表结构(account-manager v2
## 数据库路径
`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/account-manager.db`
`JIANGCHANG_DATA_ROOT``JIANGCHANG_USER_ID` 由宿主注入,与兄弟技能保持一致。路径解析细节见 [RUNTIME.md](RUNTIME.md)。
路径解析细节见 [RUNTIME.md](RUNTIME.md)。
## `accounts` 表(核心字段)
## Schema 版本
- `id` —— 自增主键,CLI 与跨技能常称「账号 id」
- `name` —— 展示用名称(如「豆包1号」)
- `platform` —— 平台英文键(小写,如 `doubao``sohu`),见 [PLATFORMS.md](PLATFORMS.md)
- `phone` —— 归一化后的 11 位数字串
- `profile_dir` —— 持久化浏览器用户数据目录绝对路径,供 Playwright 等复用
- `url` —— 该平台默认入口 URL
- `extra_json` —— 可选扩展(JSON 文本),`get` 成功时可能并入返回对象
- `created_at` / `updated_at` —— Unix 秒;`pick-web` 等按更新时间排序选候选
`PRAGMA user_version = 2`
**说明**:表中**不**存「是否已登录」字段;业务侧在真实浏览器会话中自行处理登录态。
## 1. platforms — 平台注册表
| 字段 | 类型 | 说明 |
|------|------|------|
| `platform_key` | TEXT PK | 平台唯一键maersk / deepseek / sohu 等) |
| `display_name` | TEXT NOT NULL | 展示名Maersk / DeepSeek / 搜狐号) |
| `domain` | TEXT NOT NULL | 业务域logistics / llm / content / ecommerce / generic |
| `provider_code` | TEXT | 供应商代码,物流 profile 可复用 |
| `default_url` | TEXT | 默认入口 URL |
| `aliases_json` | TEXT | 别名 JSON 数组 |
| `capabilities_json` | TEXT | 能力声明 JSON`{"web":true,"api_key":true,"rpa":true}` |
| `enabled` | INTEGER | 是否启用1=yes 0=no |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_platforms_domain`, `idx_platforms_provider_code`
## 2. accounts — 账号身份
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK AUTO | 主键 |
| `platform_key` | TEXT NOT NULL | 对应 platforms.platform_key |
| `account_label` | TEXT NOT NULL | 人可读名称 |
| `login_id` | TEXT | 登录标识email/phone/username/customer_code可空 |
| `login_id_type` | TEXT | email / username / phone / customer_code / api_account / unknown |
| `tenant_id` | TEXT | 租户/项目标识,可空 |
| `environment` | TEXT | simulator / staging / production / test |
| `role` | TEXT | booking / tracking / admin / sales / api / default |
| `provider_code` | TEXT | 冗余供应商码 |
| `profile_dir` | TEXT | Playwright 持久化浏览器 profile 目录 |
| `url` | TEXT | 账号默认入口 URL |
| `status` | TEXT | active / disabled / needs_review |
| `session_status` | TEXT | unknown / likely_valid / needs_login / expired |
| `last_used_at` | INTEGER | 最近 pick/使用时间 |
| `last_login_check_at` | INTEGER | 最近登录检查时间 |
| `last_error_code` | TEXT | 最近错误代码 |
| `last_error_message` | TEXT | 最近错误消息 |
| `extra_json` | TEXT | 扩展字段 JSON |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_accounts_platform`, `idx_accounts_pick_web`, `idx_accounts_provider`, `idx_accounts_status`
**唯一约束说明:** `(platform_key, login_id, tenant_id, environment, role)` 通过应用层检查,因为 SQLite 允许多条 NULL login_id。
## 3. credentials — 凭据API Key / Token / Password
**核心原则SQLite 只保存元数据,不保存 secret 明文。**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER | 关联 accounts.id可空平台级 API Key |
| `platform_key` | TEXT NOT NULL | 平台唯一键 |
| `credential_label` | TEXT NOT NULL | 人可读名称 |
| `credential_type` | TEXT | api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note |
| `secret_storage` | TEXT | none / env / windows_credential |
| `secret_ref` | TEXT | env 时为环境变量名windows_credential 时为 target name |
| `secret_mask` | TEXT | 打码展示sk-****abcd不可反推 |
| `expires_at` | INTEGER | 过期时间 |
| `status` | TEXT | active / disabled / expired / needs_rotation |
| `last_used_at` | INTEGER | 最近使用时间 |
| `extra_json` | TEXT | 扩展字段 JSON |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_credentials_platform_type`, `idx_credentials_account`, `idx_credentials_status`
## 4. account_leases — RPA 并发锁
防止多个任务同时打开同一个 profile_dir。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER PK AUTO | 主键 |
| `account_id` | INTEGER NOT NULL | 关联 accounts.id |
| `lease_token` | TEXT UNIQUE | 随机 token用于 release/heartbeat |
| `holder` | TEXT NOT NULL | 持有者标识 |
| `purpose` | TEXT | 用途说明 |
| `expires_at` | INTEGER NOT NULL | 过期时间 |
| `heartbeat_at` | INTEGER | 最近心跳时间 |
| `released_at` | INTEGER | 主动释放时间 |
| `status` | TEXT | active / released / expired |
| `created_at` | INTEGER | Unix 秒 UTC |
| `updated_at` | INTEGER | Unix 秒 UTC |
**索引:** `idx_leases_account_status`, `idx_leases_token`, `idx_leases_expires`
**约束:**
- acquire 前先把过期 active lease 标记为 expired
- 同一 account_id 同时只能有一个 active 未过期 lease
- release 必须校验 lease_token
## Profile 文件布局
浏览器配置目录通常位于技能数据目录下的 `profiles/` 分层路径,由实现生成。删除账号时实现会尽量同时删除对应目录,见 [CLI.md](CLI.md) `delete` 说明。
```
{DATA_ROOT}/{USER_ID}/account-manager/
├── account-manager.db
└── profiles/
└── {domain}/
└── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/
```
删除账号时实现会尽量同时删除对应目录。
## 与 JSON 输出的对应关系
`get` / `list-json` / `pick-web` 单行 JSON 的键名与上表及 `extra_json` 展开规则见 [INTEGRATION.md](INTEGRATION.md) 与仓库 `assets/schemas/`
`account get/list/pick-web` `credential pick` JSON 输出字段与上表一一对应。详见 [INTEGRATION.md](INTEGRATION.md) 与 `assets/schemas/`

View File

@@ -1,15 +1,41 @@
"""CLI 入口:用法说明与 argv 分发。"""
# -*- coding: utf-8 -*-
"""
CLI entry point: argv dispatch for account-manager v2.
Subcommand tree:
platform list/get
account add-web|add-secret|get|list|pick-web|mark-session|mark-error|mark-used
credential pick|resolve
lease release|list|cleanup
(legacy) add|list|get|list-json|pick-web|open|delete
Machine commands output single-line JSON where appropriate.
"""
import sys
from service.account_service import (
cmd_add,
cmd_account_add_secret,
cmd_account_add_web,
cmd_account_get,
cmd_account_list,
cmd_account_mark_error,
cmd_account_mark_session,
cmd_account_mark_used,
cmd_account_pick_web,
cmd_credential_pick,
cmd_credential_resolve,
cmd_delete_by_id,
cmd_delete_by_platform,
cmd_delete_by_platform_phone,
cmd_get,
cmd_lease_cleanup,
cmd_lease_list,
cmd_lease_release,
cmd_list,
cmd_list_json,
cmd_pick_web,
cmd_platform_get,
cmd_platform_list,
)
from service.browser_service import cmd_open
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
@@ -17,97 +43,223 @@ 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.runtime_paths import _maybe_print_paths_debug_cli
# =========================================================================
# Help text
# =========================================================================
def _cli_print_full_usage() -> None:
"""总览(未带子命令或需要完整说明时)。"""
print("用法概览(将 main.py 换为你的路径):")
print(" python main.py add <平台中文名> <手机号>")
print(" python main.py list [平台|all|全部]")
print(" python main.py pick-web <平台> # 跨技能用:按最近更新选一条账号,一行 JSON")
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
print(" python main.py get <id>")
print(" python main.py open <id>")
print(" python main.py delete id <id>")
print(" python main.py delete platform <平台>")
print(" python main.py delete platform <平台> <手机号>")
print(" python main.py <平台> # 等价于只列该平台")
print(" python main.py <平台> list # 同上")
print()
print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu")
print(" " + _platform_list_cn_for_help())
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
平台管理:
platform list [--domain logistics|llm|content]
platform get <platform>
账号管理:
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>]
account add-secret --platform <p> --credential-type <type>
--secret-storage env|windows_credential|none
[--secret-ref <env_var>] [--secret-stdin]
[--label <label>] [--account-id <id>]
[--environment <env>] [--role <role>]
account get <id> [--with-credentials]
account list [--platform <p>] [--environment <env>]
[--tenant-id <t>] [--role <role>] [--limit N]
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
[--role <role>] [--lease] [--ttl-sec 900]
[--holder <holder>] [--purpose <purpose>]
account mark-session <id> --session-status <status>
account mark-error <id> --code <code> --message <msg>
account mark-used <id>
account open <id>
凭据管理:
credential pick --platform <p> [--type <type>] [--environment <env>]
[--role <role>] [--reveal]
credential resolve --id <credential_id> [--reveal]
租约管理:
lease release <lease_token>
lease list [--active]
lease cleanup
浏览器:
account open <id>
open <id> # 兼容别名
兼容入口(旧版):
get <id>
list-json <platform|all> [--limit N]
pick-web <platform>
list [platform|all]
add <platform> <phone>
delete id|platform <...>
"""
def _cli_fail_add() -> None:
print("ERROR:CLI_ADD_MISSING_ARGS")
print()
print("【add 添加账号】参数不足。")
print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)")
print(" 必填:中国大陆 11 位手机号1 开头,第二位 39同平台下不可重复")
print()
print("示例:")
print(" python main.py add 搜狐号 13800138000")
print(" python main.py add 知乎 13900001111")
print()
print("支持的平台:" + _platform_list_cn_for_help())
# =========================================================================
# Argument parsing helpers
# =========================================================================
def _get_opt(argv: list[str], flag: str, default=None):
"""Get value of --flag from argv, returning default if not found."""
if flag in argv:
idx = argv.index(flag)
if idx + 1 < len(argv):
return argv[idx + 1]
return default
def _cli_fail_need_account_id(subcmd: str) -> None:
print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID")
print()
print(f"{subcmd}】缺少必填参数:账号 id数据库自增整数可先 list 查看)。")
print("示例:")
print(f" python main.py {subcmd} 1")
def _has_flag(argv: list[str], flag: str) -> bool:
return flag in argv
def _cli_fail_delete() -> None:
print("ERROR:CLI_DELETE_MISSING_ARGS")
print()
print("【delete 删除账号】参数不足。支持三种方式:")
print(" 按 idpython main.py delete id <id>")
print(" 按平台删该平台下全部python main.py delete platform <平台中文名或英文键>")
print(" 按平台+手机号python main.py delete platform <平台> <手机号>")
print()
print("示例:")
print(" python main.py delete id 3")
print(" python main.py delete platform 搜狐号")
print(" python main.py delete platform 头条号 18925203701")
print()
print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。")
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
v = _get_opt(argv, flag)
if v is not None:
try:
return int(v)
except ValueError:
return default
return default
# =========================================================================
# Main dispatch
# =========================================================================
def main() -> None:
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
log = get_skill_logger()
if len(sys.argv) < 2:
_cli_print_full_usage()
print(_USAGE)
sys.exit(1)
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
get_skill_logger().info("cli_start argv=%s", sys.argv)
cmd = sys.argv[1]
log.info("cli_start argv=%s", sys.argv)
av = sys.argv
cmd = av[1]
_maybe_print_paths_debug_cli()
# Determine if first arg is a platform key (for legacy shorthand)
plat_from_first = resolve_platform_key(cmd)
if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first:
cmd_list(plat_from_first)
elif plat_from_first and len(sys.argv) == 2:
cmd_list(plat_from_first)
elif cmd == "add":
if len(sys.argv) < 4:
_cli_fail_add()
# ---- platform subcommand ----
if cmd == "platform":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS platform 需要子命令list | get")
sys.exit(1)
cmd_add(sys.argv[2], sys.argv[3])
elif cmd == "list":
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
sub = av[2]
if sub == "list":
domain = _get_opt(av, "--domain")
cmd_platform_list(domain)
elif sub == "get":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
sys.exit(1)
cmd_platform_get(av[3])
else:
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get。")
sys.exit(1)
# ---- account subcommand ----
elif cmd == "account":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS account 需要子命令add-web / get / …)。")
print(_USAGE)
sys.exit(1)
sub = av[2]
if sub == "add-web":
_cmd_add_web(av)
elif sub == "add-secret":
_cmd_add_secret(av)
elif sub == "get":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
sys.exit(1)
aid = av[3]
with_creds = _has_flag(av, "--with-credentials")
cmd_account_get(aid, include_credentials=with_creds)
elif sub == "list":
_cmd_account_list(av)
elif sub == "pick-web":
_cmd_pick_web_new(av)
elif sub == "mark-session":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-session 需要 <id>。")
sys.exit(1)
aid = av[3]
ss = _get_opt(av, "--session-status", "unknown")
cmd_account_mark_session(aid, ss)
elif sub == "mark-error":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-error 需要 <id>。")
sys.exit(1)
aid = av[3]
ec = _get_opt(av, "--code", "")
em = _get_opt(av, "--message", "")
cmd_account_mark_error(aid, ec, em)
elif sub == "mark-used":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account mark-used 需要 <id>。")
sys.exit(1)
aid = av[3]
cmd_account_mark_used(aid)
elif sub == "open":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
sys.exit(1)
cmd_open(av[3])
else:
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
print(_USAGE)
sys.exit(1)
# ---- credential subcommand ----
elif cmd == "credential":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS credential 需要子命令pick | resolve")
sys.exit(1)
sub = av[2]
if sub == "pick":
_cmd_cred_pick(av)
elif sub == "resolve":
_cmd_cred_resolve(av)
else:
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
sys.exit(1)
# ---- lease subcommand ----
elif cmd == "lease":
if len(av) < 3:
print("ERROR:CLI_BAD_ARGS lease 需要子命令release | list | cleanup")
sys.exit(1)
sub = av[2]
if sub == "release":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
sys.exit(1)
cmd_lease_release(av[3])
elif sub == "list":
active = _has_flag(av, "--active")
cmd_lease_list(active)
elif sub == "cleanup":
cmd_lease_cleanup()
else:
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
sys.exit(1)
# ---- Legacy aliases ----
elif cmd == "get":
if len(sys.argv) < 3:
_cli_fail_need_account_id("get")
print("ERROR:CLI_GET_MISSING_ID")
sys.exit(1)
cmd_get(sys.argv[2])
elif cmd == "list-json":
if len(sys.argv) < 3:
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
print("用法python main.py list-json <平台|all|全部> [--limit N]")
print("说明:跨技能编排;成功时 stdout 仅一行 JSON 数组(元素与 get 一致)。")
sys.exit(1)
av = sys.argv[2:]
platform_arg = av[0]
@@ -120,21 +272,45 @@ def main() -> None:
except ValueError:
pass
cmd_list_json(platform_arg, limit=lim)
elif cmd == "pick-web":
if len(sys.argv) < 3:
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
print("用法python main.py pick-web <平台中文名或英文键>")
print("说明:按 updated_at 选一条账号;是否已登录由调用方网页会话自行处理。")
sys.exit(1)
cmd_pick_web(sys.argv[2])
elif cmd == "open":
if len(sys.argv) < 3:
_cli_fail_need_account_id("open")
print("ERROR:CLI_OPEN_MISSING_ID")
sys.exit(1)
cmd_open(sys.argv[2])
elif cmd == "list":
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
cmd_list(platform_arg)
elif cmd == "add":
if len(sys.argv) < 4:
print("ERROR:CLI_ADD_MISSING_ARGS")
sys.exit(1)
# Legacy add maps to add-web for backward compat
cmd_account_add_web(
platform_input=sys.argv[2],
login_id=sys.argv[3],
login_id_type="phone",
label=None,
tenant_id=None,
environment="production",
role="default",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
elif cmd == "delete":
if len(sys.argv) < 4:
_cli_fail_delete()
print("ERROR:CLI_DELETE_MISSING_ARGS")
sys.exit(1)
mode = (sys.argv[2] or "").strip().lower()
if mode == "id":
@@ -145,17 +321,151 @@ def main() -> None:
else:
cmd_delete_by_platform(sys.argv[3])
else:
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]},请使用 id 或 platform")
_cli_fail_delete()
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。")
sys.exit(1)
# ---- Platform shorthand: "python main.py <platform>" ----
elif plat_from_first:
cmd_list(sys.argv[1])
# ---- Unknown ----
else:
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法")
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。")
print()
_cli_print_full_usage()
print()
print("说明list 的筛选可为 all/全部,或上面列出的任一平台中文名。")
print(_USAGE)
sys.exit(1)
# =========================================================================
# Sub-parsers for complex commands
# =========================================================================
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")
label = _get_opt(argv, "--label")
tenant_id = _get_opt(argv, "--tenant-id")
environment = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "default")
provider_code = _get_opt(argv, "--provider-code")
url = _get_opt(argv, "--url")
extra_json_str = _get_opt(argv, "--extra-json")
profile_dir_override = _get_opt(argv, "--profile-dir")
if not plat:
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
return
cmd_account_add_web(
platform_input=plat,
login_id=login_id,
login_id_type=login_id_type,
label=label,
tenant_id=tenant_id,
environment=environment,
role=role,
provider_code=provider_code,
url=url,
extra_json_str=extra_json_str,
profile_dir_override=profile_dir_override,
)
def _cmd_add_secret(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--credential-type", "api_key")
label = _get_opt(argv, "--label")
storage = _get_opt(argv, "--secret-storage", "env")
stdin_mode = _has_flag(argv, "--secret-stdin")
sref = _get_opt(argv, "--secret-ref")
aid = _get_int_opt(argv, "--account-id", 0) or None
env = _get_opt(argv, "--environment", "production")
role = _get_opt(argv, "--role", "api")
extra_json_str = _get_opt(argv, "--extra-json")
if not plat:
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
return
cmd_account_add_secret(
platform_input=plat,
credential_type=ctype,
label=label,
secret_storage=storage,
secret_stdin=stdin_mode,
secret_ref=sref,
account_id=aid,
environment=env,
role=role,
extra_json_str=extra_json_str,
)
def _cmd_account_list(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
limit = _get_int_opt(argv, "--limit", 200)
cmd_account_list(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
limit=limit,
)
def _cmd_pick_web_new(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
env = _get_opt(argv, "--environment")
tenant = _get_opt(argv, "--tenant-id")
role = _get_opt(argv, "--role")
do_lease = _has_flag(argv, "--lease")
ttl = _get_int_opt(argv, "--ttl-sec", 900)
holder = _get_opt(argv, "--holder")
purpose = _get_opt(argv, "--purpose")
if not plat:
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
return
cmd_account_pick_web(
platform_input=plat,
environment=env,
tenant_id=tenant,
role=role,
do_lease=do_lease,
ttl_sec=ttl,
holder=holder,
purpose=purpose,
)
def _cmd_cred_pick(argv: list[str]) -> None:
plat = _get_opt(argv, "--platform", "")
ctype = _get_opt(argv, "--type")
env = _get_opt(argv, "--environment")
role = _get_opt(argv, "--role")
reveal = _has_flag(argv, "--reveal")
if not plat:
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
return
cmd_credential_pick(
platform_input=plat,
credential_type=ctype,
environment=env,
role=role,
reveal=reveal,
)
def _cmd_cred_resolve(argv: list[str]) -> None:
cid = _get_int_opt(argv, "--id", 0)
reveal = _has_flag(argv, "--reveal")
if cid <= 0:
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
return
cmd_credential_resolve(cid, reveal=reveal)
if __name__ == "__main__":
main()

View File

@@ -1,19 +1,29 @@
"""Accounts table: CRUD and queries (no stdout)."""
# -*- coding: utf-8 -*-
"""
Accounts, Credentials, Leases, and Platforms CRUD (no stdout).
All functions operate on a connection (passed or opened locally).
Time fields are INTEGER Unix seconds (UTC).
"""
import json
import time
from datetime import datetime
from typing import Any, Optional
from db.connection import get_conn, init_db
from util.platforms import PLATFORM_URLS, _PLATFORM_NAME_CN
from util.logging_config import get_skill_logger
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now_unix() -> int:
return int(time.time())
def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
"""对外 JSON本地时区 ISO8601 字符串,便于人读。"""
"""Convert Unix timestamp to local ISO8601 string, or None."""
if ts is None:
return None
try:
@@ -22,197 +32,636 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
return None
def normalize_account_id(account_id):
if account_id is None:
return None
s = str(account_id).strip()
if s.isdigit():
return int(s)
return s
def default_name_for_platform(platform: str, index: int) -> str:
cn = _PLATFORM_NAME_CN.get(platform)
if cn:
return f"{cn}{index}"
return f"{platform}_{index}"
def get_account_by_id(account_id):
init_db()
aid = normalize_account_id(account_id)
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id, name, platform, phone, profile_dir, url, extra_json,
created_at, updated_at
FROM accounts WHERE id = ?
""",
(aid,),
)
row = cur.fetchone()
if not row:
return None
acc: dict[str, Any] = {
def _acc_row_to_dict(row) -> dict[str, Any]:
"""Convert an accounts table row tuple to a dict matching the v2 schema."""
return {
"id": row[0],
"name": row[1],
"platform": row[2],
"phone": row[3] or "",
"profile_dir": (row[4] or "").strip(),
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
"created_at": _unix_to_iso_local(row[7]),
"updated_at": _unix_to_iso_local(row[8]),
"platform_key": row[1],
"account_label": row[2],
"login_id": row[3] or "",
"login_id_type": row[4],
"tenant_id": row[5] or "",
"environment": row[6],
"role": row[7],
"provider_code": row[8] or "",
"profile_dir": (row[9] or "").strip(),
"url": row[10] or "",
"status": row[11],
"session_status": row[12],
"last_used_at": _unix_to_iso_local(row[13]),
"last_login_check_at": _unix_to_iso_local(row[14]),
"last_error_code": row[15] or "",
"last_error_message": row[16] or "",
"extra_json": _safe_json_loads(row[17]),
"created_at": _unix_to_iso_local(row[18]),
"updated_at": _unix_to_iso_local(row[19]),
}
if row[6]:
try:
extra = json.loads(row[6])
if isinstance(extra, dict):
acc.update(extra)
except Exception:
pass
return acc
finally:
conn.close()
def fetch_list_rows(platform_key: str, limit: int):
"""platform_key 'all' or platform slug; returns list of row tuples (full row)."""
conn = get_conn()
try:
# ============================================================================
# Platforms
# ============================================================================
def platform_exists(conn, platform_key: str) -> bool:
cur = conn.cursor()
sql = (
"SELECT id, name, platform, phone, profile_dir, url, "
"extra_json, created_at, updated_at FROM accounts "
)
if platform_key == "all":
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
else:
cur.execute(
sql + "WHERE platform = ? ORDER BY created_at DESC, id DESC LIMIT ?",
(platform_key, int(limit)),
)
return cur.fetchall()
finally:
conn.close()
def fetch_ids_for_list_json(platform_key: str, lim: int):
conn = get_conn()
try:
cur = conn.cursor()
if platform_key == "all":
cur.execute(
"SELECT id FROM accounts ORDER BY updated_at DESC, id DESC LIMIT ?",
(lim,),
)
else:
cur.execute(
"SELECT id FROM accounts WHERE platform = ? "
"ORDER BY updated_at DESC, id DESC LIMIT ?",
(platform_key, lim),
)
return [r[0] for r in cur.fetchall()]
finally:
conn.close()
def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]:
"""该平台一条账号:按 updated_at、created_at 倒序,与是否登录无关。"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ?
ORDER BY updated_at DESC, created_at DESC, id DESC
LIMIT 1
""",
(platform_key,),
)
row = cur.fetchone()
return int(row[0]) if row else None
finally:
conn.close()
def has_duplicate_phone_conn(conn, platform_key: str, phone_store: str) -> bool:
cur = conn.cursor()
cur.execute(
"SELECT id FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_store),
)
cur.execute("SELECT 1 FROM platforms WHERE platform_key = ?", (platform_key,))
return cur.fetchone() is not None
def count_platform_accounts_conn(conn, platform_key: str) -> int:
def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform_key,))
return int(cur.fetchone()[0])
if domain:
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key",
(domain,),
)
else:
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms ORDER BY platform_key"
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]),
"created_at": _unix_to_iso_local(r[8]),
"updated_at": _unix_to_iso_local(r[9]),
})
return result
def insert_account_row(
def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
cur = conn.cursor()
cur.execute(
"SELECT platform_key, display_name, domain, provider_code, "
"default_url, aliases_json, capabilities_json, enabled, "
"created_at, updated_at FROM platforms WHERE platform_key = ?",
(platform_key,),
)
r = cur.fetchone()
if not r:
return None
return {
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"aliases": _safe_json_loads(r[5], []),
"capabilities": _safe_json_loads(r[6], {}),
"enabled": bool(r[7]),
"created_at": _unix_to_iso_local(r[8]),
"updated_at": _unix_to_iso_local(r[9]),
}
# ============================================================================
# Accounts — CRUD
# ============================================================================
def insert_account(
conn,
name: str,
platform_key: str,
phone_store: str,
profile_dir: str,
url: str,
account_label: str,
login_id: Optional[str],
login_id_type: str,
tenant_id: Optional[str],
environment: str,
role: str,
provider_code: Optional[str],
profile_dir: Optional[str],
url: Optional[str],
extra_json: Optional[str],
now: int,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO accounts (name, platform, phone, profile_dir, url, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
INSERT INTO accounts
(platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url,
status, session_status, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?)
""",
(name, platform_key, phone_store, profile_dir, url, now, now),
(
platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url,
extra_json or "{}", now, now,
),
)
return int(cur.lastrowid)
def fetch_delete_row_by_id_conn(conn, aid: int):
def get_account_by_id(account_id) -> Optional[dict]:
"""Get a single account by id. Returns dict or None."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute(
"SELECT id, name, platform, phone, profile_dir FROM accounts WHERE id = ?",
(aid,),
"""
SELECT id, platform_key, account_label, login_id, login_id_type,
tenant_id, environment, role, provider_code,
profile_dir, url, status, session_status,
last_used_at, last_login_check_at,
last_error_code, last_error_message, extra_json,
created_at, updated_at
FROM accounts WHERE id = ?
""",
(int(account_id),),
)
return cur.fetchone()
row = cur.fetchone()
if not row:
return None
return _acc_row_to_dict(row)
finally:
conn.close()
def delete_account_by_id_conn(conn, rid: int) -> None:
def find_accounts(
platform_key: Optional[str] = None,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
status: Optional[str] = None,
provider_code: Optional[str] = None,
limit: int = 200,
) -> list[dict]:
"""Find accounts by optional filters. Returns list of dicts."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE id = ?", (rid,))
conditions = []
params = []
if platform_key:
conditions.append("platform_key = ?")
params.append(platform_key)
if environment:
conditions.append("environment = ?")
params.append(environment)
if tenant_id:
conditions.append("tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("role = ?")
params.append(role)
if status:
conditions.append("status = ?")
params.append(status)
if provider_code:
conditions.append("provider_code = ?")
params.append(provider_code)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
sql = (
"SELECT id, platform_key, account_label, login_id, login_id_type, "
"tenant_id, environment, role, provider_code, profile_dir, url, "
"status, session_status, last_used_at, last_login_check_at, "
"last_error_code, last_error_message, extra_json, created_at, updated_at "
f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?"
)
params.append(int(limit))
cur.execute(sql, params)
return [_acc_row_to_dict(row) for row in cur.fetchall()]
finally:
conn.close()
def fetch_ids_profile_for_platform_conn(conn, platform_key: str):
def find_account_ids(
platform_key: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
) -> list[int]:
"""Return account IDs matching filters, excluding those with active leases."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["a.status = 'active'", "a.platform_key = ?"]
params = [platform_key]
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if tenant_id:
conditions.append("a.tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("a.role = ?")
params.append(role)
# Exclude accounts with active non-expired leases
now = _now_unix()
conditions.append(
"a.id NOT IN (SELECT account_id FROM account_leases "
"WHERE status = 'active' AND expires_at > ?)"
)
params.append(now)
where = " AND ".join(conditions)
cur.execute(
f"SELECT a.id FROM accounts a WHERE {where} "
"ORDER BY CASE WHEN a.session_status = 'expired' THEN 1 ELSE 0 END ASC, "
"a.last_used_at IS NULL DESC, a.updated_at DESC, a.id DESC",
params,
)
return [int(row[0]) for row in cur.fetchall()]
finally:
conn.close()
def update_account_last_used(account_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, profile_dir FROM accounts WHERE platform = ?",
(platform_key,),
"UPDATE accounts SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), account_id),
)
return cur.fetchall()
conn.commit()
finally:
conn.close()
def delete_accounts_by_platform_conn(conn, platform_key: str) -> None:
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE platform = ?", (platform_key,))
def fetch_row_platform_phone_conn(conn, platform_key: str, phone_norm: str):
def update_account_session_status(account_id: int, session_status: str) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, profile_dir FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_norm),
"UPDATE accounts SET session_status = ?, last_login_check_at = ?, updated_at = ? WHERE id = ?",
(session_status, _now_unix(), _now_unix(), account_id),
)
return cur.fetchone()
conn.commit()
finally:
conn.close()
def delete_account_platform_phone_conn(conn, platform_key: str, phone_norm: str) -> None:
def update_account_error(
account_id: int,
error_code: str,
error_message: str,
) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"DELETE FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_norm),
"UPDATE accounts SET last_error_code = ?, last_error_message = ?, updated_at = ? WHERE id = ?",
(error_code, error_message, _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
cur = conn.cursor()
cur.execute(
"SELECT id FROM accounts WHERE platform_key = ? AND login_id = ? AND environment = ? AND role = ?",
(platform_key, login_id, environment, role),
)
return cur.fetchone() is not None
# ============================================================================
# Credentials
# ============================================================================
def insert_credential(
conn,
account_id: Optional[int],
platform_key: str,
credential_label: str,
credential_type: str,
secret_storage: str,
secret_ref: Optional[str],
secret_mask: Optional[str],
expires_at: Optional[int],
extra_json: Optional[str],
now: int,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO credentials
(account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at, status,
extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
""",
(
account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at,
extra_json or "{}", now, now,
),
)
return int(cur.lastrowid)
def get_credential_by_id(credential_id: int) -> Optional[dict]:
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute(
"SELECT id, account_id, platform_key, credential_label, credential_type, "
"secret_storage, secret_ref, secret_mask, expires_at, status, last_used_at, "
"extra_json, created_at, updated_at FROM credentials WHERE id = ?",
(credential_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
}
finally:
conn.close()
def find_credentials(
platform_key: str,
credential_type: Optional[str] = None,
environment: Optional[str] = None,
role: Optional[str] = None,
status: str = "active",
limit: int = 50,
) -> list[dict]:
"""Find credentials, optionally joining accounts for environment/role filter."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["c.status = ?"]
params = [status]
conditions.append("c.platform_key = ?")
params.append(platform_key)
if credential_type:
conditions.append("c.credential_type = ?")
params.append(credential_type)
# If environment/role filters provided, join accounts table
if environment or role:
conditions.append("c.account_id = a.id")
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if role:
conditions.append("a.role = ?")
params.append(role)
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c, accounts a WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
else:
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
rows = cur.fetchall()
result = []
for row in rows:
result.append({
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
})
return result
finally:
conn.close()
def update_credential_last_used(credential_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE credentials SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), credential_id),
)
conn.commit()
finally:
conn.close()
# ============================================================================
# Leases
# ============================================================================
def acquire_lease(
account_id: int,
lease_token: str,
holder: str,
purpose: Optional[str],
ttl_sec: int,
) -> dict:
"""
Acquire a lease for an account. Expires active leases first.
Returns the lease record dict on success.
Raises ValueError if a non-expired active lease already exists.
"""
log = get_skill_logger()
log.info("lease_acquire_attempt account_id=%s holder=%s purpose=%s ttl=%s", account_id, holder, purpose, ttl_sec)
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
now = _now_unix()
# Mark any expired active leases as expired
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE account_id = ? AND status = 'active' AND expires_at <= ?",
(now, account_id, now),
)
if cur.rowcount > 0:
log.info("lease_expired_cleanup account_id=%s count=%s", account_id, cur.rowcount)
# Check for existing active non-expired lease
cur.execute(
"SELECT id, lease_token, holder FROM account_leases "
"WHERE account_id = ? AND status = 'active' AND expires_at > ?",
(account_id, now),
)
existing = cur.fetchone()
if existing:
log.warning(
"lease_conflict account_id=%s existing_holder=%s existing_token_prefix=%s",
account_id, existing[2], existing[1][:8],
)
raise ValueError(
f"Account {account_id} already has an active lease "
f"(holder={existing[2]}, token_prefix={existing[1][:8]}) (LEASE_CONFLICT)"
)
expires_at = now + max(1, int(ttl_sec))
cur.execute(
"""
INSERT INTO account_leases
(account_id, lease_token, holder, purpose, expires_at, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
""",
(account_id, lease_token, holder, purpose, expires_at, now, now),
)
conn.commit()
lease_id = int(cur.lastrowid)
log.info("lease_acquire_success account_id=%s lease_id=%s token_prefix=%s", account_id, lease_id, lease_token[:8])
return {
"id": lease_id,
"account_id": account_id,
"lease_token": lease_token,
"holder": holder,
"purpose": purpose,
"expires_at": _unix_to_iso_local(expires_at),
"status": "active",
"created_at": _unix_to_iso_local(now),
}
finally:
conn.close()
def release_lease(lease_token: str) -> bool:
"""Release a lease by token. Returns True if found and released."""
log = get_skill_logger()
log.info("lease_release_attempt token_prefix=%s", lease_token[:8])
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? "
"WHERE lease_token = ? AND status = 'active'",
(now, now, lease_token),
)
conn.commit()
if cur.rowcount == 0:
log.warning("lease_release_not_found token_prefix=%s", lease_token[:8])
return False
log.info("lease_release_success token_prefix=%s", lease_token[:8])
return True
finally:
conn.close()
def list_active_leases() -> list[dict]:
"""List all active (non-expired, non-released) leases."""
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"SELECT id, account_id, lease_token, holder, purpose, expires_at, "
"heartbeat_at, released_at, status, created_at, updated_at "
"FROM account_leases WHERE status = 'active' AND expires_at > ? "
"ORDER BY created_at DESC",
(now,),
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"id": r[0],
"account_id": r[1],
"lease_token": r[2],
"holder": r[3],
"purpose": r[4] or "",
"expires_at": _unix_to_iso_local(r[5]),
"heartbeat_at": _unix_to_iso_local(r[6]),
"released_at": _unix_to_iso_local(r[7]),
"status": r[8],
"created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
})
return result
finally:
conn.close()
def cleanup_expired_leases() -> int:
"""Mark all expired active leases as expired. Returns count cleaned."""
log = get_skill_logger()
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE status = 'active' AND expires_at <= ?",
(now, now),
)
count = cur.rowcount
conn.commit()
if count > 0:
log.info("lease_cleanup_expired count=%s", count)
return count
finally:
conn.close()
# ============================================================================
# Internal helpers
# ============================================================================
def _safe_json_loads(val: Any, default: Any = None) -> Any:
if val is None:
return default
try:
return json.loads(val) if isinstance(val, str) else val
except (json.JSONDecodeError, TypeError):
return default

View File

@@ -1,21 +1,124 @@
"""SQLite connection and schema bootstrap."""
import sqlite3
# -*- coding: utf-8 -*-
"""
SQLite connection with schema migration (v1 -> v2).
from db.schema import ACCOUNTS_TABLE_SQL
Migration strategy:
- Check PRAGMA user_version.
- If version < 2 and old 'accounts' table exists, back it up as
'accounts_legacy_backup_<timestamp>' before creating new tables.
- New tables are created with CREATE TABLE IF NOT EXISTS.
- Logs all migration steps.
"""
import os
import sqlite3
import time
from typing import Optional
from db.schema import ALL_DDL, SCHEMA_VERSION
from util.constants import SKILL_SLUG
from util.logging_config import get_skill_logger
from util.runtime_paths import get_db_path
def get_conn():
return sqlite3.connect(get_db_path())
def get_conn() -> sqlite3.Connection:
"""Open a connection to the skill database (autocommit off)."""
conn = sqlite3.connect(get_db_path())
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF")
return conn
def init_db():
def _backup_old_accounts(conn: sqlite3.Connection) -> bool:
"""Rename old 'accounts' table to 'accounts_legacy_backup_<ts>' if it exists with old schema.
Returns True if backup was performed."""
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cur.fetchone():
return False
# Check if it's the old schema (has 'phone' column but no 'platform_key')
# We just check the column count — old schema had fewer columns.
cur.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cur.fetchall()]
# Old schema: id, name, platform, phone, profile_dir, url, extra_json, created_at, updated_at = 9
# New schema: many more. If it looks like old, back it up.
has_legacy_structure = "phone" in columns and "platform_key" not in columns
if not has_legacy_structure:
return False
ts = int(time.time())
backup_name = f"accounts_legacy_backup_{ts}"
cur.execute(f"ALTER TABLE accounts RENAME TO {backup_name}")
conn.commit()
logger = get_skill_logger()
logger.info(
"schema_migration_backup old_table=accounts backup_table=%s",
backup_name,
)
return True
def _check_schema_version(conn: sqlite3.Connection) -> int:
cur = conn.cursor()
cur.execute("PRAGMA user_version")
row = cur.fetchone()
return int(row[0]) if row else 0
def _create_new_tables(conn: sqlite3.Connection) -> None:
"""Execute ALL_DDL (all CREATE TABLE/INDEX IF NOT EXISTS)."""
cur = conn.cursor()
cur.executescript(ALL_DDL)
conn.commit()
def init_db() -> None:
"""Idempotent: migrate if needed, create tables, set schema version."""
log = get_skill_logger()
log.info("schema_init_start")
conn = get_conn()
try:
old_version = _check_schema_version(conn)
log.info("schema_current_version=%s", old_version)
if old_version < SCHEMA_VERSION:
if old_version < 2:
backed_up = _backup_old_accounts(conn)
if backed_up:
log.info("schema_init_backup_done old_version=%s", old_version)
_create_new_tables(conn)
cur = conn.cursor()
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit()
log.info(
"schema_init_done version=%s migrated_from=%s",
SCHEMA_VERSION,
old_version,
)
else:
log.info("schema_init_uptodate version=%s", SCHEMA_VERSION)
# Ensure all tables exist even if schema version matches
_create_new_tables(conn)
cur = conn.cursor()
cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit()
except Exception:
log.exception("schema_init_failure")
raise
finally:
conn.close()
def get_db_version() -> int:
"""Return current PRAGMA user_version without initializing."""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
if not cur.fetchone():
cur.executescript(ACCOUNTS_TABLE_SQL)
conn.commit()
cur.execute("PRAGMA user_version")
row = cur.fetchone()
return int(row[0]) if row else 0
finally:
conn.close()

View File

@@ -1,16 +1,145 @@
# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601.
# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句.
ACCOUNTS_TABLE_SQL = """
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定 */
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增)
name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」
platform TEXT NOT NULL, -- 平台标识, 与内置 PLATFORMS 键一致, 如 sohu
phone TEXT, -- 可选绑定手机号
profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/
url TEXT, -- 平台入口或登录页 URL
extra_json TEXT, -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC
);
# -*- coding: utf-8 -*-
"""
Database schema v2 — Credential & Browser Profile Manager.
Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in
sqlite_master, visible in DBeaver / Navicat.
Schema version management:
PRAGMA user_version = 2 (set in connection.py after migration).
"""
import textwrap
# ---------------------------------------------------------------------------
# platforms — Registry of all platforms (logistics, LLM, content, etc.)
# ---------------------------------------------------------------------------
PLATFORMS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS platforms (
platform_key TEXT PRIMARY KEY, -- 平台唯一键,例如 maersk / deepseek / sohu
display_name TEXT NOT NULL, -- 展示名称,例如 Maersk / DeepSeek / 搜狐号
domain TEXT NOT NULL, -- 业务域logistics / llm / content / ecommerce / generic
provider_code TEXT, -- 供应商代码,物流 profile 可复用;例如 maersk、cma_cgm
default_url TEXT, -- 默认入口 URL
aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名
capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true}
enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no)
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
PLATFORMS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_platforms_domain ON platforms(domain);
CREATE INDEX IF NOT EXISTS idx_platforms_provider_code ON platforms(provider_code);
""")
# ---------------------------------------------------------------------------
# accounts — An identity that can be used (web account, RPA profile, API account)
# ---------------------------------------------------------------------------
ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
platform_key TEXT NOT NULL, -- 对应 platforms.platform_key
account_label TEXT NOT NULL, -- 人可读名称,例如 Maersk simulator booking
login_id TEXT, -- 登录标识:邮箱、用户名、手机号、客户代码等
login_id_type TEXT NOT NULL DEFAULT 'unknown', -- email / username / phone / customer_code / api_account / unknown
tenant_id TEXT, -- 租户/客户/项目标识,可空
environment TEXT NOT NULL DEFAULT 'production',-- simulator / staging / production / test
role TEXT NOT NULL DEFAULT 'default', -- booking / tracking / admin / sales / api / default
provider_code TEXT, -- 冗余供应商码,便于业务 skill 查询
profile_dir TEXT, -- Playwright 持久化浏览器用户数据目录web/rpa 账号使用
url TEXT, -- 账号默认入口 URL为空时用 platforms.default_url
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / needs_review
session_status TEXT NOT NULL DEFAULT 'unknown', -- unknown / likely_valid / needs_login / expired
last_used_at INTEGER, -- 最近被业务 skill pick/使用时间
last_login_check_at INTEGER, -- 最近一次登录状态检查时间,可空
last_error_code TEXT, -- 最近一次错误代码
last_error_message TEXT, -- 最近一次错误消息
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
ACCOUNTS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_key);
CREATE INDEX IF NOT EXISTS idx_accounts_pick_web ON accounts(platform_key, environment, tenant_id, role, status, updated_at);
CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider_code);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
""")
# Note: Unique constraint (platform_key, login_id, tenant_id, environment, role)
# is intentionally NOT added here because SQLite allows multiple NULL login_id.
# Application-level checks are used when login_id is provided.
# ---------------------------------------------------------------------------
# credentials — Secret-type credentials (API Key, Token, Password, etc.)
# Raw secret is NEVER stored in SQLite. Only metadata, secret_ref, secret_mask.
# ---------------------------------------------------------------------------
CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 凭据主键(自增)
account_id INTEGER, -- 可空;关联 accounts.id有些 API Key 可直接挂平台,也可挂具体账号
platform_key TEXT NOT NULL, -- 平台唯一键
credential_label TEXT NOT NULL, -- 人可读名称,例如 DeepSeek main API key
credential_type TEXT NOT NULL, -- api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note
secret_storage TEXT NOT NULL, -- none / env / windows_credential
secret_ref TEXT, -- env 时为环境变量名windows_credential 时为 target namenone 时为空
secret_mask TEXT, -- 打码展示,例如 sk-****abcd不能反推出原文
expires_at INTEGER, -- 过期时间Unix 秒 UTC可空
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation
last_used_at INTEGER, -- 最近使用时间
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
CREDENTIALS_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_credentials_platform_type ON credentials(platform_key, credential_type);
CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id);
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status);
""")
# ---------------------------------------------------------------------------
# account_leases — RPA / profile concurrency locks
# Prevents multiple tasks from opening the same profile_dir simultaneously.
# ---------------------------------------------------------------------------
ACCOUNT_LEASES_TABLE_SQL = textwrap.dedent("""\
CREATE TABLE IF NOT EXISTS account_leases (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 租约主键(自增)
account_id INTEGER NOT NULL, -- 关联 accounts.id
lease_token TEXT NOT NULL UNIQUE, -- 随机 token用于 release / heartbeat
holder TEXT NOT NULL, -- 持有者,例如 monitor-competitor-rates 或 run id
purpose TEXT, -- 用途,例如 maersk_sim_rpa / booking_collect
expires_at INTEGER NOT NULL, -- 租约过期时间Unix 秒 UTC
heartbeat_at INTEGER, -- 最近一次心跳时间
released_at INTEGER, -- 主动释放时间
status TEXT NOT NULL DEFAULT 'active', -- active / released / expired
created_at INTEGER NOT NULL, -- 记录创建时间Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间Unix 秒 UTC
);
""")
ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\
CREATE INDEX IF NOT EXISTS idx_leases_account_status ON account_leases(account_id, status);
CREATE INDEX IF NOT EXISTS idx_leases_token ON account_leases(lease_token);
CREATE INDEX IF NOT EXISTS idx_leases_expires ON account_leases(expires_at);
""")
# ---------------------------------------------------------------------------
# Full DDL assembly
# ---------------------------------------------------------------------------
ALL_DDL = (
PLATFORMS_TABLE_SQL
+ PLATFORMS_INDEXES_SQL
+ ACCOUNTS_TABLE_SQL
+ ACCOUNTS_INDEXES_SQL
+ CREDENTIALS_TABLE_SQL
+ CREDENTIALS_INDEXES_SQL
+ ACCOUNT_LEASES_TABLE_SQL
+ ACCOUNT_LEASES_INDEXES_SQL
)
SCHEMA_VERSION = 2

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,28 @@
"""Chromium/Edge 检测与 Playwright仅打开浏览器不写库、不做登录态 DOM 检测)。"""
# -*- coding: utf-8 -*-
"""
Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
Does NOT download Playwright Chromium.
Does NOT check login status.
Does NOT write secret to SQLite.
"""
import json
import os
import subprocess
import sys
import tempfile
from db.connection import init_db
from db.accounts_repo import get_account_by_id
from util.logging_config import (
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORM_URLS
from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import若以「脚本路径」启动子进程
# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
@@ -68,11 +75,17 @@ def _print_browser_install_hint():
def cmd_open(account_id):
"""打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录。"""
get_skill_logger().info("open account_id=%r", account_id)
"""
打开该账号的持久化浏览器,仅用于肉眼核对。
不写数据库、不判定是否已登录。
"""
log = get_skill_logger()
init_db()
log.info("browser_open_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
get_skill_logger().warning("open_not_found account_id=%r", account_id)
log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
return
@@ -85,23 +98,33 @@ def cmd_open(account_id):
import playwright # noqa: F401
except ImportError:
print(
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright"
"本机需安装 Google Chrome 或 Microsoft Edgechannel 模式,无需 playwright install chromium"
"ERROR:需要 playwright Python 包"
)
return
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
print("ERROR:PROFILE_DIR_MISSING 库中 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)
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
target["platform"], "https://www.google.com"
# Use account url, fall back to platform default
platform_spec = PLATFORMS.get(target["platform_key"], {})
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "")
log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
profile_dir,
url,
channel,
target.get("platform_key"),
)
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测。")
print("本命令不做登录检测。")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
@@ -115,6 +138,10 @@ def cmd_open(account_id):
cwd=_SCRIPTS_ROOT,
env=subprocess_env_with_trace(),
)
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)

View File

@@ -0,0 +1,306 @@
# -*- coding: utf-8 -*-
"""
Secret storage abstraction layer.
Supported storage backends:
- none : No secret stored (e.g., browser profile login state)
- env : Secret is read from environment variable at runtime
- windows_credential : Secret stored in Windows Credential Manager (ctypes)
Raw secret values are NEVER logged or written to SQLite.
"""
import os
import re
import sys
import uuid
from util.logging_config import get_skill_logger
def mask_secret(secret: str) -> str:
"""
Return a masked version of the secret for display/storage.
Never returns the full original secret or a reversible prefix.
Examples:
- Long API key -> \"****abcd\" (last 4 chars only, after separator)
- Short / empty -> \"****\"
"""
if secret is None:
return "****"
s = str(secret).strip()
if not s:
return "****"
if len(s) <= 4:
return "****"
return "****" + s[-4:]
def make_windows_credential_target(
platform_key: str,
credential_type: str,
credential_id_or_label: str,
) -> str:
"""
Generate a Windows Credential Manager target name for a credential.
Format: openclaw/account-manager/{user_id}/{platform_key}/{credential_type}/{uuid_short}
"""
user_id = (
(os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
or "_anon"
)
safe_label = re.sub(r"[^a-zA-Z0-9_-]", "_", credential_id_or_label)[:32]
uid = uuid.uuid4().hex[:8]
return (
f"openclaw/account-manager/{user_id}/"
f"{platform_key}/{credential_type}/{safe_label}_{uid}"
)
# ---------------------------------------------------------------------------
# Write
# ---------------------------------------------------------------------------
def write_secret(storage: str, secret_ref: str, secret_value: str) -> None:
"""
Write a secret to the specified storage backend.
Raises ValueError on unsupported storage or write failure.
"""
log = get_skill_logger()
log.info(
"secret_write_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
log.info("secret_write_success storage=none ref=%s", secret_ref)
return
elif storage_n == "env":
log.info("secret_write_success storage=env ref=%s", secret_ref)
return
elif storage_n == "windows_credential":
_win_cred_write_impl(secret_ref, secret_value)
log.info(
"secret_write_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return
else:
log.error("secret_write_failed unsupported storage=%s", storage)
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
def read_secret(storage: str, secret_ref: str) -> str:
"""
Read/retrieve a secret from the specified storage backend.
Returns the secret value as a string.
Raises ValueError on unsupported storage, missing ref, or read failure.
"""
log = get_skill_logger()
log.info(
"secret_resolve_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
log.info("secret_resolve_success storage=none")
return ""
elif storage_n == "env":
val = os.environ.get(secret_ref)
if val is None:
log.error("secret_resolve_failed env_missing ref=%s", secret_ref)
raise ValueError(
f"Environment variable '{secret_ref}' is not set (SECRET_ENV_MISSING)"
)
log.info("secret_resolve_success storage=env ref=%s", secret_ref)
return val
elif storage_n == "windows_credential":
val = _win_cred_read_impl(secret_ref)
log.info(
"secret_resolve_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return val
else:
log.error("secret_resolve_failed unsupported storage=%s", storage)
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def delete_secret(storage: str, secret_ref: str) -> None:
"""Delete a secret from the specified storage backend."""
log = get_skill_logger()
log.info(
"secret_delete_attempt storage=%s ref_prefix=%s",
storage,
secret_ref[:16] if secret_ref else "none",
)
storage_n = storage.strip().lower()
if storage_n == "none":
return
elif storage_n == "env":
log.info("secret_delete_env_nop ref=%s", secret_ref)
return
elif storage_n == "windows_credential":
_win_cred_delete_impl(secret_ref)
log.info(
"secret_delete_success storage=windows_credential ref=%s",
secret_ref[:48],
)
return
else:
raise ValueError(f"Unsupported secret storage type: {storage}")
# ---------------------------------------------------------------------------
# Windows Credential Manager implementation (ctypes)
# ---------------------------------------------------------------------------
_WINCRED_TYPE_GENERIC = 1 # CRED_TYPE_GENERIC
_WINCRED_PERSIST_LOCAL_MACHINE = 2 # CRED_PERSIST_LOCAL_MACHINE
def _win_cred_available() -> bool:
"""Check if Windows Credential Manager API is available."""
if sys.platform != "win32":
return False
try:
import ctypes # noqa: F401
return True
except ImportError:
return False
def _win_cred_unavailable() -> OSError:
return OSError(
"Windows Credential Manager is not available on this platform "
"(WINDOWS_CREDENTIAL_UNAVAILABLE)"
)
def _win_cred_write_impl(target: str, secret_value: str) -> None:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
from ctypes import wintypes
class CREDENTIALW(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPCWSTR),
("Comment", wintypes.LPCWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", ctypes.c_void_p),
("TargetAlias", wintypes.LPCWSTR),
("UserName", wintypes.LPCWSTR),
]
secret_bytes = secret_value.encode("utf-16-le")
blob_arr = (ctypes.c_byte * len(secret_bytes)).from_buffer_copy(secret_bytes)
cred = CREDENTIALW()
cred.Type = _WINCRED_TYPE_GENERIC
cred.TargetName = target
cred.CredentialBlobSize = len(secret_bytes)
cred.CredentialBlob = ctypes.cast(blob_arr, ctypes.POINTER(ctypes.c_byte))
cred.Persist = _WINCRED_PERSIST_LOCAL_MACHINE
cred.UserName = "account-manager"
advapi32 = ctypes.windll.advapi32
if not advapi32.CredWriteW(ctypes.byref(cred), 0):
err = ctypes.get_last_error()
raise OSError(f"CredWriteW failed with error {err} (SECRET_WRITE_FAILED)")
del blob_arr, cred
def _win_cred_read_impl(target: str) -> str:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
from ctypes import wintypes
class CREDENTIALW(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPCWSTR),
("Comment", wintypes.LPCWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", ctypes.c_void_p),
("TargetAlias", wintypes.LPCWSTR),
("UserName", wintypes.LPCWSTR),
]
PCREDENTIALW = ctypes.POINTER(CREDENTIALW)
advapi32 = ctypes.windll.advapi32
advapi32.CredReadW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.POINTER(PCREDENTIALW),
]
advapi32.CredReadW.restype = wintypes.BOOL
advapi32.CredFree.argtypes = [ctypes.c_void_p]
advapi32.CredFree.restype = None
ppcred = PCREDENTIALW()
if not advapi32.CredReadW(target, _WINCRED_TYPE_GENERIC, 0, ctypes.byref(ppcred)):
err = ctypes.get_last_error()
if err == 1168:
raise ValueError(
f"Credential not found in Windows Credential Manager: target={target}"
)
raise OSError(f"CredReadW failed with error {err} (SECRET_READ_FAILED)")
try:
cred = ppcred.contents
blob_size = int(cred.CredentialBlobSize)
if blob_size <= 0 or not cred.CredentialBlob:
return ""
raw = ctypes.string_at(cred.CredentialBlob, blob_size)
return raw.decode("utf-16-le")
finally:
advapi32.CredFree(ppcred)
def _win_cred_delete_impl(target: str) -> None:
if not _win_cred_available():
raise _win_cred_unavailable()
import ctypes
advapi32 = ctypes.windll.advapi32
if not advapi32.CredDeleteW(target, _WINCRED_TYPE_GENERIC, 0):
err = ctypes.get_last_error()
if err != 1168:
raise OSError(f"CredDeleteW failed with error {err}")

View File

@@ -1,145 +1,341 @@
"""平台配置、别名解析、手机号校验。"""
import re
# -*- coding: utf-8 -*-
"""
Platform definitions and resolution.
# 平台唯一配置表:只改这里即可。键 = 入库的 platformlabel = 帮助/提示里的展示名;
# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label
# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。
# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。
# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。
# - login_logged_out_selectors按平台追加误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。
PLATFORMS = {
"sohu": {
"url": "https://mp.sohu.com",
"label": "搜狐号",
"prefix": "搜狐",
"aliases": ["搜狐"],
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
"""
import json
import time
from typing import Any, Optional
from util.logging_config import get_skill_logger
# ---------------------------------------------------------------------------
# Platform specification fields:
# display_name Human-readable name (e.g. "Maersk")
# domain Business domain: logistics / llm / content / ecommerce / generic
# provider_code Supplier code (logistics); can be same as key
# default_url Default entry URL (empty string if none)
# aliases List of name aliases (Chinese/English, for CLI resolution)
# capabilities Dict of capability flags:
# {"web": bool, "api_key": bool, "rpa": bool}
# ---------------------------------------------------------------------------
PLATFORMS: dict[str, dict[str, Any]] = {
# =========================================================================
# Logistics platforms
# =========================================================================
"maersk": {
"display_name": "Maersk",
"domain": "logistics",
"provider_code": "maersk",
"default_url": "https://www.maersk.com",
"aliases": ["马士基", "maersk", "Maersk"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"toutiao": {
"url": "https://mp.toutiao.com/",
"label": "头条号",
"prefix": "头条",
"aliases": ["头条"],
"cosco": {
"display_name": "COSCO",
"domain": "logistics",
"provider_code": "cosco",
"default_url": "",
"aliases": ["中远海运", "COSCO", "cosco"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"zhihu": {
"url": "https://www.zhihu.com",
"label": "知乎",
"aliases": ["知乎号"],
"msc": {
"display_name": "MSC",
"domain": "logistics",
"provider_code": "msc",
"default_url": "",
"aliases": ["MSC", "地中海航运"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"wechat": {
"url": "https://mp.weixin.qq.com",
"label": "微信公众号",
"prefix": "微信",
"aliases": ["公众号", "微信"],
"cma_cgm": {
"display_name": "CMA CGM",
"domain": "logistics",
"provider_code": "cma_cgm",
"default_url": "",
"aliases": ["达飞", "CMA CGM", "cma cgm"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"kimi": {
"url": "https://kimi.moonshot.cn",
"label": "Kimi",
"aliases": ["月之暗面"],
"evergreen": {
"display_name": "Evergreen",
"domain": "logistics",
"provider_code": "evergreen",
"default_url": "",
"aliases": ["长荣", "Evergreen", "evergreen"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"cargo_wise": {
"display_name": "CargoWise",
"domain": "logistics",
"provider_code": "cargo_wise",
"default_url": "",
"aliases": ["CargoWise", "Cargo Wise", "cargo wise"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
"freightos": {
"display_name": "Freightos",
"domain": "logistics",
"provider_code": "freightos",
"default_url": "",
"aliases": ["Freightos", "freightos"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"webcargo": {
"display_name": "WebCargo",
"domain": "logistics",
"provider_code": "webcargo",
"default_url": "",
"aliases": ["WebCargo", "web cargo"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"sinotrans": {
"display_name": "Sinotrans",
"domain": "logistics",
"provider_code": "sinotrans",
"default_url": "",
"aliases": ["中外运", "Sinotrans", "sinotrans"],
"capabilities": {"web": True, "api_key": False, "rpa": True},
},
# =========================================================================
# LLM / AI platforms
# =========================================================================
"deepseek": {
"url": "https://chat.deepseek.com",
"label": "DeepSeek",
"display_name": "DeepSeek",
"domain": "llm",
"provider_code": "deepseek",
"default_url": "https://chat.deepseek.com",
"aliases": ["DeepSeek", "deepseek"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"doubao": {
"url": "https://www.doubao.com",
"label": "豆包",
"display_name": "Doubao",
"domain": "llm",
"provider_code": "doubao",
"default_url": "https://www.doubao.com",
"aliases": ["豆包", "Doubao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"kimi": {
"display_name": "Kimi",
"domain": "llm",
"provider_code": "kimi",
"default_url": "https://kimi.moonshot.cn",
"aliases": ["Kimi", "月之暗面"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"qianwen": {
"url": "https://tongyi.aliyun.com",
"label": "通义千问",
"prefix": "通义",
"aliases": ["通义", "千问"],
"display_name": "Qianwen",
"domain": "llm",
"provider_code": "qianwen",
"default_url": "https://tongyi.aliyun.com",
"aliases": ["通义千问", "通义", "千问", "Qianwen"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yiyan": {
"url": "https://yiyan.baidu.com",
"label": "文心一言",
"prefix": "文心",
"aliases": ["文心", "一言"],
"display_name": "YiYan",
"domain": "llm",
"provider_code": "yiyan",
"default_url": "https://yiyan.baidu.com",
"aliases": ["文心一言", "文心", "一言", "YiYan"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"yuanbao": {
"url": "https://yuanbao.tencent.com",
"label": "腾讯元宝",
"prefix": "元宝",
"aliases": ["元宝"],
"display_name": "Yuanbao",
"domain": "llm",
"provider_code": "yuanbao",
"default_url": "https://yuanbao.tencent.com",
"aliases": ["腾讯元宝", "元宝", "Yuanbao"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"gemini": {
"url": "https://gemini.google.com",
"label": "Gemini",
"prefix": "Gemini",
"aliases": ["谷歌Gemini", "Google Gemini", "google gemini", "Bard"],
"display_name": "Gemini",
"domain": "llm",
"provider_code": "gemini",
"default_url": "https://gemini.google.com",
"aliases": ["Gemini", "谷歌Gemini", "Google Gemini", "Bard"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
"minimax": {
"display_name": "MiniMax",
"domain": "llm",
"provider_code": "minimax",
"default_url": "",
"aliases": ["MiniMax", "minimax"],
"capabilities": {"web": True, "api_key": True, "rpa": False},
},
# =========================================================================
# Content platforms (carried forward from v1)
# =========================================================================
"sohu": {
"display_name": "搜狐号",
"domain": "content",
"provider_code": "sohu",
"default_url": "https://mp.sohu.com",
"aliases": ["搜狐", "搜狐号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"toutiao": {
"display_name": "头条号",
"domain": "content",
"provider_code": "toutiao",
"default_url": "https://mp.toutiao.com/",
"aliases": ["头条", "头条号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"zhihu": {
"display_name": "知乎",
"domain": "content",
"provider_code": "zhihu",
"default_url": "https://www.zhihu.com",
"aliases": ["知乎", "知乎号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
"wechat": {
"display_name": "微信公众号",
"domain": "content",
"provider_code": "wechat",
"default_url": "https://mp.weixin.qq.com",
"aliases": ["公众号", "微信", "微信公众号"],
"capabilities": {"web": True, "api_key": False, "rpa": False},
},
}
# 未登录页共性主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等Playwright :has-text 可覆盖多数站点。
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = (
'button:has-text("登录")',
'role=button[name="登录"]',
'a:has-text("登录")',
'h4:has-text("登录")',
'span.semi-button-content:has-text("登录")',
)
def _build_platform_derived():
urls = {}
name_prefix = {}
primary_cn = {}
alias_to_key = {}
def _register_alias(alias: str, key: str) -> None:
if not alias or not str(alias).strip():
return
a = str(alias).strip()
if a not in alias_to_key:
alias_to_key[a] = key
lo = a.lower()
if lo not in alias_to_key:
alias_to_key[lo] = key
# ---------------------------------------------------------------------------
# Derived alias map
# ---------------------------------------------------------------------------
def _build_alias_map() -> dict[str, str]:
"""Build a case-insensitive flat alias -> key map."""
m: dict[str, str] = {}
for key, spec in PLATFORMS.items():
urls[key] = spec["url"]
primary_cn[key] = spec["label"]
name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key
_register_alias(key, key)
_register_alias(spec["label"], key)
for a in spec.get("aliases") or []:
_register_alias(a, key)
return urls, name_prefix, primary_cn, alias_to_key
# Register the key itself (lowercase)
m[key.lower()] = key
# Register all aliases (original + lowercase)
for alias in [spec["display_name"]] + spec.get("aliases", []):
a = str(alias).strip()
if a:
m[a] = key
m[a.lower()] = key
return m
PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived()
_PLATFORM_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str):
"""将用户输入解析为内部 platform 键;无法识别返回 None。"""
if name is None:
def resolve_platform_key(name: str) -> Optional[str]:
"""
Resolve user input to a canonical platform key.
Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized.
"""
if not name:
return None
s = str(name).strip()
if not s:
return None
sl = s.lower()
if sl in PLATFORM_URLS:
# Direct key match
if sl in PLATFORMS:
return sl
return _PLATFORM_ALIAS_TO_KEY.get(s)
# Alias map
if sl in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[sl]
if s in _PLATFORM_ALIAS_MAP:
return _PLATFORM_ALIAS_MAP[s]
return None
def _normalize_phone_digits(phone: str) -> str:
"""从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。"""
d = re.sub(r"\D", "", (phone or "").strip())
if d.startswith("86") and len(d) >= 13:
d = d[2:]
return d
def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated key."""
return PLATFORMS.get(key)
def _is_valid_cn_mobile11(digits: str) -> bool:
"""中国大陆 11 位手机号1 开头,第二位 39共 11 位数字。"""
return bool(re.fullmatch(r"1[3-9]\d{9}", digits or ""))
def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]:
"""List all platforms, optionally filtered by domain.
Returns a list of dicts with metadata."""
result = []
for key, spec in PLATFORMS.items():
if domain and spec["domain"] != domain:
continue
result.append({
"platform_key": key,
"display_name": spec["display_name"],
"domain": spec["domain"],
"provider_code": spec.get("provider_code"),
"default_url": spec.get("default_url", ""),
"aliases": spec.get("aliases", []),
"capabilities": spec.get("capabilities", {}),
"enabled": True,
})
return result
def seed_platforms(conn) -> None:
"""
Upsert all platform definitions into the 'platforms' table.
Must be called after init_db() in the same connection lifecycle.
"""
log = get_skill_logger()
log.info("platform_seed_start")
cur = conn.cursor()
now = int(time.time())
upsert_count = 0
for key, spec in PLATFORMS.items():
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
cur.execute(
"""
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
ON CONFLICT(platform_key) DO UPDATE SET
display_name = excluded.display_name,
domain = excluded.domain,
provider_code = excluded.provider_code,
default_url = excluded.default_url,
aliases_json = excluded.aliases_json,
capabilities_json = excluded.capabilities_json,
enabled = 1,
updated_at = excluded.updated_at
""",
(
key,
spec["display_name"],
spec["domain"],
spec.get("provider_code"),
spec.get("default_url", ""),
aliases_json,
capabilities_json,
now,
now,
),
)
upsert_count += 1
conn.commit()
log.info("platform_seed_done count=%s", upsert_count)
# ---------------------------------------------------------------------------
# Legacy compatibility helpers (for old account_service v1 code references)
# ---------------------------------------------------------------------------
# Kept for backward compatibility so old imports don't break immediately.
# New code should use PLATFORMS dict directly and resolve_platform_key().
_PLATFORM_PRIMARY_CN: dict[str, str] = {
k: spec["display_name"] for k, spec in PLATFORMS.items()
}
PLATFORM_URLS: dict[str, str] = {
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
}
def _platform_list_cn_for_help() -> str:
"""帮助文案:一行中文展示名(不重复内部键)"""
parts = []
for k in sorted(PLATFORM_URLS.keys()):
parts.append(_PLATFORM_PRIMARY_CN.get(k, k))
return "".join(parts)
"""帮助文案:一行展示名,供 CLI 用法提示"""
return "".join(
spec["display_name"] for _k, spec in sorted(
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
)
)

View File

@@ -1,4 +1,7 @@
"""数据目录、profile 路径、CLI 路径调试输出。"""
# -*- coding: utf-8 -*-
"""
Data directory, profile path, CLI path debugging output.
"""
import os
import re
import sys
@@ -6,21 +9,20 @@ from typing import Optional
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
from util.constants import SKILL_SLUG
from util.platforms import _PLATFORM_PRIMARY_CN
def get_skill_data_dir():
def get_skill_data_dir() -> str:
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
os.makedirs(path, exist_ok=True)
return path
def get_db_path():
def get_db_path() -> str:
return os.path.join(get_skill_data_dir(), "account-manager.db")
def _runtime_paths_debug_text():
"""供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。"""
def _runtime_paths_debug_text() -> str:
"""For debugging: print current data path info to stderr."""
env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)"
env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)"
return (
@@ -33,8 +35,8 @@ def _runtime_paths_debug_text():
)
def _maybe_print_paths_debug_cli():
"""设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。"""
def _maybe_print_paths_debug_cli() -> None:
"""Set JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 to print paths in stderr."""
v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower()
if v in ("1", "true", "yes", "on"):
print(_runtime_paths_debug_text(), file=sys.stderr)
@@ -49,7 +51,7 @@ _WIN_RESERVED_NAMES = frozenset(
def _fs_safe_segment(name: str, fallback: str) -> str:
"""单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback"""
"""Single directory segment: strip Windows-unsafe chars; empty -> fallback."""
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
t = t.rstrip(" .")
if not t:
@@ -59,22 +61,33 @@ def _fs_safe_segment(name: str, fallback: str) -> str:
return t
def get_default_profile_dir(
platform_key: str, phone: Optional[str], account_id: Optional[int] = None
def get_default_profile_dir_for_web(
platform_key: str,
label: str,
login_id: Optional[str] = None,
domain: str = "generic",
) -> str:
"""
默认可读路径profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。
无手机号时退化为 profiles/<平台展示名>/no_phone_<id>/。
Generate a browser profile directory path.
Structure:
{DATA_ROOT}/{USER_ID}/account-manager/profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix]/
This keeps profiles organized by domain and platform for easy
identification in file explorer.
"""
label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account")
label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform"))
ph = (phone or "").strip()
if ph:
phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone")
else:
phone_seg = _fs_safe_segment(
"", f"no_phone_{account_id}" if account_id is not None else "no_phone"
label_seg = _fs_safe_segment(label, platform_key)
login_suffix = ""
if login_id:
safe_login = _fs_safe_segment(login_id, "")
if safe_login:
login_suffix = f"_{safe_login[:16]}"
path = os.path.join(
get_skill_data_dir(),
"profiles",
_fs_safe_segment(domain, "generic"),
_fs_safe_segment(platform_key, "unknown"),
f"{label_seg}{login_suffix}",
)
path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg)
os.makedirs(path, exist_ok=True)
return path

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# account-manager tests

49
tests/conftest.py Normal file
View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
Test fixtures for account-manager tests.
All tests use an isolated temporary database under a fake JIANGCHANG_DATA_ROOT
so they never touch real user data.
"""
import os
import sys
import tempfile
import shutil
# Ensure scripts/ is on the path
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
import pytest
@pytest.fixture(scope="session")
def fake_env():
"""Set up a fake JIANGCHANG_DATA_ROOT / JIANGCHANG_USER_ID for the session."""
tmp = tempfile.mkdtemp(prefix="account_manager_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root)
fake_uid = "99999"
os.environ["JIANGCHANG_DATA_ROOT"] = data_root
os.environ["JIANGCHANG_USER_ID"] = fake_uid
yield {"data_root": data_root, "user_id": fake_uid, "tmp_dir": tmp}
@pytest.fixture(scope="function")
def clean_db(fake_env):
"""Each test gets a fresh in-memory DB (init_db uses the env vars set by fake_env)."""
# Import after env vars are set
from db.connection import get_conn, init_db
from util.constants import SKILL_SLUG
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_test")
init_db()
conn = get_conn()
conn.execute("DELETE FROM account_leases")
conn.execute("DELETE FROM credentials")
conn.execute("DELETE FROM accounts")
conn.commit()
yield conn
conn.close()

21
tests/run_tests.py Normal file
View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
"""
Test runner for account-manager.
Run:
pytest tests/ -v
python tests/run_tests.py -v
"""
import os
import sys
_script_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
if __name__ == "__main__":
import pytest
tests_dir = os.path.dirname(os.path.abspath(__file__))
args = [tests_dir, "-v"] + sys.argv[1:]
sys.exit(pytest.main(args))

View File

@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""
Tests for account CRUD operations and pick-web filtering.
"""
import json
import os
import pytest
class TestAccountAddWeb:
"""account add-web creates records correctly without requiring phone numbers."""
def test_add_web_email_login_id(self, clean_db):
"""Email login_id should be accepted (no 11-digit phone restriction)."""
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="maersk",
login_id="demo@maersk.com",
login_id_type="email",
label="Maersk sim test",
tenant_id="tenant-demo",
environment="simulator",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
# Should succeed with JSON output
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="maersk")
assert len(accs) == 1
assert accs[0]["login_id"] == "demo@maersk.com"
assert accs[0]["login_id_type"] == "email"
assert accs[0]["environment"] == "simulator"
assert accs[0]["role"] == "booking"
assert accs[0]["tenant_id"] == "tenant-demo"
assert accs[0]["status"] == "active"
assert accs[0]["platform_key"] == "maersk"
def test_add_web_no_login_id(self, clean_db):
"""login_id can be None (e.g. for browser_profile-only accounts)."""
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="deepseek",
login_id=None,
login_id_type="unknown",
label="DeepSeek prod",
tenant_id=None,
environment="production",
role="api",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="deepseek")
assert len(accs) == 1
assert accs[0]["login_id"] == ""
def test_add_web_profile_dir_created(self, clean_db, fake_env):
"""profile_dir should be auto-created under fake_env data root."""
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="maersk",
login_id="test@example.com",
login_id_type="email",
label="Profile dir test",
tenant_id="tenant-test",
environment="production",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="maersk")
assert len(accs) == 1
profile_dir = accs[0]["profile_dir"]
assert profile_dir, "profile_dir should be non-empty"
assert os.path.isdir(profile_dir), f"profile_dir should be created: {profile_dir}"
# The path should be under the fake data root
assert profile_dir.startswith(fake_env["data_root"]), \
f"profile_dir should be under fake data root: {profile_dir}"
def test_add_web_extra_json(self, clean_db):
"""extra_json field is stored and retrieved correctly."""
from service.account_service import cmd_account_add_web
extra = {"api_version": "v1", "timeout": 30}
cmd_account_add_web(
platform_input="cma_cgm",
login_id="cma@test.com",
login_id_type="email",
label="CMA CGM test",
tenant_id="tenant-cma",
environment="production",
role="documentation",
provider_code=None,
url=None,
extra_json_str=json.dumps(extra),
profile_dir_override=None,
)
from db.accounts_repo import find_accounts
accs = find_accounts(platform_key="cma_cgm")
assert len(accs) == 1
raw_ex = accs[0].get("extra_json")
extra_parsed = raw_ex if isinstance(raw_ex, dict) else json.loads(raw_ex or "{}")
assert extra_parsed["api_version"] == "v1"
assert extra_parsed["timeout"] == 30
class TestAccountGetList:
"""account get/list operations."""
def test_get_account_found(self, clean_db):
"""account get returns JSON with correct account fields."""
from service.account_service import cmd_account_add_web, cmd_account_get
import io
import sys
# Add account first
cmd_account_add_web(
platform_input="evergreen",
login_id="ship@evergreen.com",
login_id_type="email",
label="Evergreen booking",
tenant_id="tenant-evg",
environment="production",
role="booking",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
accs = find_accounts_()
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_get(accs[0]["id"])
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
data = json.loads(output)
assert data["platform_key"] == "evergreen"
assert data["login_id"] == "ship@evergreen.com"
def test_get_account_not_found(self, clean_db):
"""account get for unknown id returns ERROR:ACCOUNT_NOT_FOUND."""
from service.account_service import cmd_account_get
import io
import sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_get(9999)
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
assert "ERROR:ACCOUNT_NOT_FOUND" in output
def test_list_platform_filter(self, clean_db):
"""account list --platform filters by platform."""
from service.account_service import cmd_account_add_web, cmd_account_list
import io
import sys
cmd_account_add_web("maersk", "a@maersk.com", "email", "m", "t1", "prod", "booking", None, None, None, None)
cmd_account_add_web("maersk", "b@maersk.com", "email", "m2", "t1", "prod", "documentation", None, None, None, None)
cmd_account_add_web("cma_cgm", "c@cma.com", "email", "c", "t2", "prod", "booking", None, None, None, None)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_list(platform_input="maersk")
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
accs = json.loads(output)
assert len(accs) == 2
assert all(a["platform_key"] == "maersk" for a in accs)
def find_accounts_():
from db.accounts_repo import find_accounts
return find_accounts(limit=200)
class TestPickWebFiltering:
"""pick-web filtering by environment / role / tenant_id."""
def _add(self, platform, login, label, tenant, env, role):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform, login, "email", label, tenant, env, role,
None, None, None, None,
)
def test_filter_environment(self, clean_db):
"""pick-web --environment simulator selects only simulator account."""
self._add("maersk", "sim@maersk.com", "S", "t1", "simulator", "booking")
self._add("maersk", "prod@maersk.com", "P", "t1", "production", "booking")
self._add("maersk", "prod2@maersk.com", "P2", "t1", "production", "documentation")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("maersk", environment="simulator", tenant_id=None, role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["login_id"] == "sim@maersk.com"
assert result["environment"] == "simulator"
def test_filter_role(self, clean_db):
"""pick-web --role booking selects only booking role."""
self._add("maersk", "a@maersk.com", "A", "t1", "simulator", "booking")
self._add("maersk", "b@maersk.com", "B", "t1", "simulator", "documentation")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("maersk", environment=None, tenant_id=None, role="documentation", do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["role"] == "documentation"
def test_filter_tenant(self, clean_db):
"""pick-web --tenant-id filters correctly."""
self._add("cma_cgm", "a@cma.com", "A", "tenant-a", "production", "booking")
self._add("cma_cgm", "b@cma.com", "B", "tenant-b", "production", "booking")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("cma_cgm", environment=None, tenant_id="tenant-b", role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["tenant_id"] == "tenant-b"
def test_no_accounts_returns_error(self, clean_db):
"""pick-web when no account matches returns ERROR:NO_ACCOUNT."""
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("maersk", environment="nonexistent", tenant_id=None, role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
output = captured.getvalue()
err = json.loads(output)
assert err["success"] is False
assert err["error"]["code"] == "ERROR:NO_ACCOUNT"
def test_platform_alias_chinese(self, clean_db):
"""pick-web resolves Chinese platform alias correctly."""
self._add("maersk", "test@test.com", "Test", "t", "production", "booking")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web("马士基", environment=None, tenant_id=None, role=None, do_lease=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["platform_key"] == "maersk"

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""
Browser launch helper checks (no real browser started).
"""
import pytest
class TestPlaywrightLaunchHelpers:
"""Persistent context launch parts must match RPA contract."""
def test_persistent_context_includes_maximized(self):
from util.playwright_stealth import persistent_context_launch_parts
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",
)
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
class TestResolveChromiumChannel:
"""resolve_chromium_channel returns a channel string when browsers exist."""
def test_returns_string_or_none(self):
from service.browser_service import resolve_chromium_channel
ch = resolve_chromium_channel()
assert ch is None or ch in ("chrome", "msedge")

340
tests/test_credentials.py Normal file
View File

@@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""
Tests for credential operations, secret storage, and reveal.
Note: windows_credential integration tests are skipped by default
(the real Windows Credential Manager requires admin and a real user session).
Secret store logic for windows_credential is tested via mocking.
"""
import json
import os
import pytest
class TestCredentialEnvStorage:
"""credential add-secret env / pick --reveal / SQLite no-secret verification."""
def test_add_secret_env_saves_ref_not_value(self, clean_db):
"""add-secret with storage=env stores only the env var name, not the secret."""
from service.account_service import cmd_account_add_secret
from db.connection import get_conn
# Set the env var so the command can read it if needed
os.environ["TEST_DEEPSEEK_KEY"] = "sk-test-1234567890abcdef"
try:
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="DeepSeek test key",
secret_storage="env",
secret_stdin=False,
secret_ref="TEST_DEEPSEEK_KEY",
account_id=None,
environment="production",
role="api",
extra_json_str=None,
)
finally:
del os.environ["TEST_DEEPSEEK_KEY"]
conn = get_conn()
cur = conn.cursor()
cur.execute(
"SELECT secret_ref, secret_mask, secret_storage FROM credentials WHERE platform_key = ?",
("deepseek",),
)
row = cur.fetchone()
assert row is not None, "credential should be inserted"
secret_ref, secret_mask, secret_storage = row
assert secret_storage == "env"
assert secret_ref == "TEST_DEEPSEEK_KEY", "secret_ref should be the env var name"
# secret_mask should be a masked form of "env:TEST_DEEPSEEK_KEY", NOT the actual secret
assert "sk-test" not in secret_mask, "secret_mask must not contain the actual secret value"
assert "****" in secret_mask, "mask should contain ****"
conn.close()
def test_credential_pick_reveal_reads_environ(self, clean_db):
"""credential pick --reveal retrieves secret value from os.environ."""
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
os.environ["MY_SECRET_KEY"] = "sk-actual-secret-value-abcdefgh"
try:
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="Reveal test",
secret_storage="env",
secret_stdin=False,
secret_ref="MY_SECRET_KEY",
account_id=None,
environment=None,
role=None,
extra_json_str=None,
)
# credential pick --reveal
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert result["secret"] == "sk-actual-secret-value-abcdefgh"
assert result["secret_mask"] != result["secret"], "mask should differ from secret"
finally:
del os.environ["MY_SECRET_KEY"]
def test_credential_pick_no_reveal_masks(self, clean_db):
"""credential pick (no --reveal) does NOT include the secret field."""
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
os.environ["MASK_TEST_KEY"] = "super-secret-value-xyz"
try:
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="Mask test",
secret_storage="env",
secret_stdin=False,
secret_ref="MASK_TEST_KEY",
account_id=None,
environment=None,
role=None,
extra_json_str=None,
)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="api_key", reveal=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert "secret" not in result, "no secret field when reveal=False"
assert result["secret_mask"] != "super-secret-value-xyz"
finally:
del os.environ["MASK_TEST_KEY"]
def test_env_missing_returns_error(self, clean_db):
"""credential pick --reveal when env var is missing returns SECRET_ENV_MISSING."""
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
# Add credential with a ref to a non-existent env var
cmd_account_add_secret(
platform_input="deepseek",
credential_type="api_key",
label="Missing env test",
secret_storage="env",
secret_stdin=False,
secret_ref="THIS_ENV_VAR_DOES_NOT_EXIST_AT_ALL",
account_id=None,
environment=None,
role=None,
extra_json_str=None,
)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:SECRET_ENV_MISSING"
class TestCredentialNoneStorage:
"""credential add-secret storage=none (browser_profile / note)."""
def test_add_secret_none(self, clean_db):
"""storage=none works for browser_profile credential type."""
from service.account_service import cmd_account_add_secret
cmd_account_add_secret(
platform_input="maersk",
credential_type="browser_profile",
label="Maersk browser session",
secret_storage="none",
secret_stdin=False,
secret_ref=None,
account_id=None,
environment="simulator",
role="booking",
extra_json_str=None,
)
from db.connection import get_conn
conn = get_conn()
cur = conn.cursor()
cur.execute(
"SELECT secret_storage, secret_ref, secret_mask FROM credentials WHERE platform_key = ?",
("maersk",),
)
row = cur.fetchone()
assert row is not None
assert row[0] == "none"
assert row[1] is None or row[1] == ""
conn.close()
class TestCredentialWindowsCredentialMocked:
"""Mocked tests for windows_credential secret store logic."""
def test_mask_secret_never_returns_full_value(self):
"""mask_secret must never return the actual secret value."""
from service.secret_store import mask_secret
secrets = [
"sk-test-1234567890abcdefghijklmnop",
"short",
"abc",
"a" * 100,
"",
None,
]
for s in secrets:
if s is None or s == "":
masked = mask_secret(s)
assert masked == "****"
continue
result = mask_secret(s)
# The mask should never equal the original
assert result != s, f"mask_secret({s!r}) must not equal original"
# The mask should always contain "****"
assert "****" in result, f"mask should contain '****': {result}"
# For long secrets, original should not appear in mask
assert s not in result, f"original secret must not appear in mask: {s}"
def test_read_secret_env_success(self):
"""read_secret with storage=env returns the os.environ value."""
os.environ["TEST_ENV_FOR_READ"] = "env-secret-value-xyz"
try:
from service.secret_store import read_secret
val = read_secret("env", "TEST_ENV_FOR_READ")
assert val == "env-secret-value-xyz"
finally:
del os.environ["TEST_ENV_FOR_READ"]
def test_read_secret_env_missing_raises(self):
"""read_secret with storage=env raises ValueError when env var is missing."""
# Ensure the env var is not set
os.environ.pop("NONEXISTENT_VAR_FOR_TEST_123", None)
from service.secret_store import read_secret
with pytest.raises(ValueError) as exc_info:
read_secret("env", "NONEXISTENT_VAR_FOR_TEST_123")
assert "SECRET_ENV_MISSING" in str(exc_info.value)
def test_read_secret_none(self):
"""read_secret with storage=none returns empty string."""
from service.secret_store import read_secret
val = read_secret("none", "ignored-ref")
assert val == ""
def test_write_secret_none_is_noop(self):
"""write_secret with storage=none does not raise."""
from service.secret_store import write_secret
# Should not raise
write_secret("none", "ignored-ref", "any-secret-value")
def test_windows_credential_write_fails_on_non_win32(self, monkeypatch):
"""On non-Windows, windows_credential write raises OSError."""
import sys
monkeypatch.setattr(sys, "platform", "linux")
from service.secret_store import write_secret
with pytest.raises(OSError) as exc_info:
write_secret("windows_credential", "some-target", "some-secret")
assert "WINDOWS_CREDENTIAL_UNAVAILABLE" in str(exc_info.value) or \
"not available" in str(exc_info.value).lower()
class TestCredentialPickFiltering:
"""credential pick filtering by credential_type / environment."""
def test_pick_by_credential_type(self, clean_db):
from service.account_service import cmd_account_add_secret, cmd_credential_pick
import io, sys
os.environ["CREDS_TYPE_KEY_A"] = "val-a"
os.environ["CREDS_TYPE_KEY_B"] = "val-b"
try:
cmd_account_add_secret(
"deepseek", "api_key", "A", "env", False, "CREDS_TYPE_KEY_A",
None, None, None, None,
)
cmd_account_add_secret(
"deepseek", "model_param", "B", "env", False, "CREDS_TYPE_KEY_B",
None, None, None, None,
)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="model_param", reveal=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert result["credential_type"] == "model_param"
finally:
del os.environ["CREDS_TYPE_KEY_A"]
del os.environ["CREDS_TYPE_KEY_B"]
def test_pick_no_credential_returns_error(self, clean_db):
"""credential pick when none matches returns CREDENTIAL_NOT_FOUND."""
from service.account_service import cmd_credential_pick
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_credential_pick("deepseek", credential_type="nonexistent_type", reveal=False)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:CREDENTIAL_NOT_FOUND"
class TestSQLiteNoPlaintextSecret:
"""Verify that SQLite never stores actual secret values."""
def test_credentials_table_never_has_plaintext(self, clean_db):
"""All credential rows should have secret_mask != original secret value."""
from service.account_service import cmd_account_add_secret
from db.connection import get_conn
test_secret = "SQLITE_NO_PLAINTEXT_TEST_SECRET_987654321"
os.environ["SQLITE_NO_PLAINTEXT_TEST"] = test_secret
try:
cmd_account_add_secret(
"deepseek", "api_key", "SQLite test", "env", False,
"SQLITE_NO_PLAINTEXT_TEST", None, None, None, None,
)
finally:
del os.environ["SQLITE_NO_PLAINTEXT_TEST"]
conn = get_conn()
cur = conn.cursor()
cur.execute("SELECT secret_ref, secret_mask FROM credentials")
rows = cur.fetchall()
conn.close()
for secret_ref, secret_mask in rows:
# secret_ref for env storage is the env var name, not the secret
if secret_ref == "SQLITE_NO_PLAINTEXT_TEST":
# If it matches our test env var name, check the mask
assert test_secret not in (secret_mask or ""), \
f"Plaintext secret must not appear in secret_mask or secret_ref columns. mask={secret_mask}"

242
tests/test_lease.py Normal file
View File

@@ -0,0 +1,242 @@
# -*- coding: utf-8 -*-
"""
Tests for lease acquire / conflict / release / cleanup.
"""
import json
import time
import pytest
class TestLeaseAcquire:
"""Lease acquisition on pick-web."""
def _add_account(self, platform, login, label, tenant, env, role):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform, login, "email", label, tenant, env, role,
None, None, None, None,
)
def test_pick_web_lease_returns_token(self, clean_db):
"""pick-web --lease returns lease_token in result."""
self._add_account("maersk", "test@maersk.com", "Test", "t1", "production", "booking")
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"maersk",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="test-holder",
purpose="rpa-run",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert "lease_token" in result
assert len(result["lease_token"]) == 32
assert "lease_expires_at" in result
assert result["lease_token"] not in ("", "none")
def test_active_lease_blocks_pick(self, clean_db):
"""When the only matching account is leased, pick-web finds no eligible row."""
from db.accounts_repo import acquire_lease, find_account_ids
self._add_account("cma_cgm", "test@cma.com", "CMA", "t1", "production", "booking")
ids = find_account_ids("cma_cgm")
assert len(ids) == 1
acc_id = ids[0]
acquire_lease(
account_id=acc_id,
lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
holder="first-holder",
purpose="first-task",
ttl_sec=900,
)
assert find_account_ids("cma_cgm") == []
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"cma_cgm",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="second-holder",
purpose="second-task",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:NO_ACCOUNT"
def test_release_then_pick(self, clean_db):
"""After release, the same account can be picked again."""
from db.accounts_repo import acquire_lease, release_lease, find_account_ids
self._add_account("evergreen", "ship@evergreen.com", "EVG", "t1", "production", "booking")
ids = find_account_ids("evergreen")
acc_id = ids[0]
token = "z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4"
acquire_lease(acc_id, token, "holder", "purpose", 900)
ok = release_lease(token)
assert ok, "release should succeed"
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"evergreen",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="new-holder",
purpose="new-task",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue().strip().splitlines()[-1])
assert result["id"] == acc_id
assert "lease_token" in result
class TestLeaseRelease:
"""lease release command."""
def test_release_unknown_token(self, clean_db):
"""release of non-existent token returns LEASE_NOT_FOUND."""
from service.account_service import cmd_lease_release
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_lease_release("nonexistent_token_123456789012")
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is False
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
class TestLeaseCleanup:
"""lease cleanup / expired lease handling."""
def test_expired_lease_allows_pick(self, clean_db):
"""After cleanup marks expired, account can be picked again."""
from db.accounts_repo import acquire_lease, cleanup_expired_leases, find_account_ids
self._add_account_("cargo_wise", "cw@cargowise.com", "CW", "t1", "production", "booking")
ids = find_account_ids("cargo_wise")
acc_id = ids[0]
# Acquire lease with very short TTL (1 second)
acquire_lease(acc_id, "shortlived1234567890123456789012", "holder", "short", 1)
# Wait for it to expire
time.sleep(1.5)
count = cleanup_expired_leases()
assert count >= 1, "at least one lease should be cleaned"
# Now pick-web should succeed
from service.account_service import cmd_account_pick_web
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_account_pick_web(
"cargo_wise",
environment=None,
tenant_id=None,
role=None,
do_lease=True,
ttl_sec=900,
holder="after-cleanup",
purpose="fresh-task",
)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue().strip().splitlines()[-1])
assert result["id"] == acc_id
assert "lease_token" in result
def _add_account_(self, platform, login, label, tenant, env, role):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform, login, "email", label, tenant, env, role,
None, None, None, None,
)
class TestLeaseList:
"""lease list command."""
def test_list_no_leases(self, clean_db):
"""lease list --active returns empty when no leases."""
from service.account_service import cmd_lease_list
import io, sys
captured = io.StringIO()
sys.stdout = captured
try:
cmd_lease_list(active_only=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
assert result["data"]["leases"] == []
def test_list_with_active_lease(self, clean_db):
"""lease list --active shows active lease after acquisition."""
from db.accounts_repo import acquire_lease, find_account_ids
from service.account_service import cmd_account_add_web, cmd_lease_list
import io, sys
cmd_account_add_web(
"sinotrans", "sinotest@sinotrans.com", "email", "Sino", "t1", "production", "booking",
None, None, None, None,
)
ids = find_account_ids("sinotrans")
acquire_lease(ids[0], "listtest1234567890123456789012345", "holder", "purpose", 900)
captured = io.StringIO()
sys.stdout = captured
try:
cmd_lease_list(active_only=True)
finally:
sys.stdout = sys.__stdout__
result = json.loads(captured.getvalue())
assert result["success"] is True
leases = result["data"]["leases"]
assert len(leases) == 1
assert leases[0]["account_id"] == ids[0]
assert leases[0]["status"] == "active"

83
tests/test_platforms.py Normal file
View File

@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""
Platform resolution tests.
"""
import pytest
class TestPlatformResolution:
"""Platform key resolution: key / display_name / aliases -> canonical key."""
def test_maersk_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("maersk") == "maersk"
def test_maersk_alias_chinese(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("马士基") == "maersk"
def test_maersk_alias_upper(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("MAERSK") == "maersk"
def test_cma_cgm_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("cma_cgm") == "cma_cgm"
def test_cma_cgm_alias_chinese(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("达飞") == "cma_cgm"
def test_cma_cgm_alias_display(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("CMA CGM") == "cma_cgm"
def test_deepseek_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("deepseek") == "deepseek"
def test_deepseek_alias_capitalize(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("DeepSeek") == "deepseek"
def test_unknown_returns_none(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("not_a_platform_xyz") is None
def test_empty_returns_none(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("") is None
assert resolve_platform_key(None) is None
def test_list_platforms_all(self):
from util.platforms import list_platforms
all_plat = list_platforms()
assert any(p["platform_key"] == "maersk" for p in all_plat)
assert any(p["platform_key"] == "deepseek" for p in all_plat)
assert any(p["platform_key"] == "sohu" for p in all_plat)
def test_list_platforms_logistics_only(self):
from util.platforms import list_platforms
log_plat = list_platforms("logistics")
assert any(p["platform_key"] == "maersk" for p in log_plat)
assert any(p["platform_key"] == "cma_cgm" for p in log_plat)
assert not any(p["platform_key"] == "deepseek" for p in log_plat)
def test_list_platforms_llm_only(self):
from util.platforms import list_platforms
llm_plat = list_platforms("llm")
assert any(p["platform_key"] == "deepseek" for p in llm_plat)
assert any(p["platform_key"] == "doubao" for p in llm_plat)
assert not any(p["platform_key"] == "maersk" for p in llm_plat)
def test_evergreen_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("evergreen") == "evergreen"
def test_evergreen_alias_chinese(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("长荣") == "evergreen"
def test_cargo_wise_key(self):
from util.platforms import resolve_platform_key
assert resolve_platform_key("cargo_wise") == "cargo_wise"