From 3f1cde1f99a1facb5be46826e03cc19e835ed057 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 22 Aug 2026 15:40:30 +0800 Subject: [PATCH] fix: validate platform keys at the gate and stop auto-register Co-authored-by: Cursor --- SKILL.md | 35 ++-- assets/actions.json | 43 +++++ references/CLI.md | 3 +- references/ERRORS.md | 8 +- references/INTEGRATION.md | 3 + references/PLATFORMS.md | 9 +- references/README.md | 3 +- scripts/cli/app.py | 2 +- scripts/service/_svc_common.py | 97 ++-------- scripts/service/platform_commands.py | 44 +++-- scripts/util/platform_validate.py | 264 +++++++++++++++++++++++++++ scripts/util/platforms.py | 86 +-------- tests/test_custom_platforms.py | 91 +++++++-- tests/test_platform_dynamic.py | 57 ++++++ tests/test_row_open_actions.py | 12 ++ 15 files changed, 543 insertions(+), 214 deletions(-) create mode 100644 scripts/util/platform_validate.py diff --git a/SKILL.md b/SKILL.md index fd6c52f..97a92b2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 账号与凭据管理 description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" -version: 2.0.23 +version: 2.0.24 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -28,22 +28,23 @@ allowed-tools: ## 何时使用本技能 1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行) -2. **登记网页/RPA 账号**:`account add-web` — 平台 + 账号名称 + 可选登录账号即可;环境/角色/登录策略跟平台默认。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。 -3. **复制账号**:`account copy ` — 同平台新空 Profile,名称自动加「副本」,登录账号留空。数据管理行内「复制」一点即走。 -4. **编辑账号**:`account update --id ` — 只改名称、登录账号、标识类型、入口链接、状态。数据管理「编辑」已绑定(`replaces: update`),不再摊开整张表。 -5. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。 -6. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。 -7. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 -8. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 -9. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 -10. **打开浏览器**:`account open `(或兼容别名 `open `)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理「打开目录 / 打开浏览器」成功不弹提示。 -11. **打开目录**:`account reveal-profile ` — 在系统文件管理器中打开该账号 `profile_dir`。 -12. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 -13. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 -14. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 -15. **按 lease 取明文密码**:`account get-credential --reveal --lease-token ` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。 -16. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。 -17. **有则用、无则建(网页账号)**:`account ensure-web --platform

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

` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。 ## CLI 调用 diff --git a/assets/actions.json b/assets/actions.json index 4c084a0..09a0ca9 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -2,6 +2,49 @@ "schemaVersion": 1, "skill": "account-manager", "actions": [ + { + "id": "platform-ensure", + "label": "新增平台", + "description": "登记平台:小写英文键 + 展示名必须分开。不会把中文名当成键。", + "replaces": "create", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["platforms"] + }, + "inputSchema": { + "type": "object", + "required": ["platform_key", "display_name"], + "properties": { + "platform_key": { + "type": "string", + "title": "平台键", + "description": "小写英文/数字/下划线或连字符,例如 douyin、deepseek。不要填中文。" + }, + "display_name": { + "type": "string", + "title": "展示名称", + "description": "给人看的名称,可以是中文,例如 抖音。" + }, + "default_url": { + "type": "string", + "title": "默认链接" + } + } + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }], + "entrypoint": { + "type": "cli", + "command": "platform", + "args": [ + "ensure", + "--key", "{{platform_key}}", + "--display-name", "{{display_name}}", + "--url", "{{default_url}}" + ] + } + }, { "id": "account-add-web", "label": "新增账号", diff --git a/references/CLI.md b/references/CLI.md index 962b058..707007e 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -29,6 +29,7 @@ |---|---| | `platform list [--domain logistics\|llm\|content]` | 列出平台注册(`--domain` 仅接受这三种;`dev`/`sim` 等平台请省略 `--domain` 查全量或在 JSON 里筛选) | | `platform get ` | 获取数据库中的平台详情 | +| `platform ensure --key --display-name [--url …]` | **登记/更新平台**。`--key` 必须已是小写英文键(如 `douyin`);`--display-name` **必填**(可中文)。不会从 `add-web` / `pick-web` 自动建平台。 | ### B. 加密初始化(v2) @@ -223,7 +224,7 @@ ACCOUNT_MANAGER_MASTER_KEY="" python main.py account init-maste ## 通用说明 -- **平台参数**:可为 `scripts/util/platforms.py` 中的英文键、`display_name`、任一 alias(不区分大小写)。详见 [PLATFORMS.md](PLATFORMS.md)。 +- **平台参数**:已注册的英文键、或能唯一对应到一个键的 `display_name`/alias(不区分大小写)。冲突别名(如「公众号」同时对应 `wechat` / `wechat_mp`)会返回 `AMBIGUOUS_PLATFORM`,请改传明确键。未注册的键**不会**自动插入 `platforms` 表。详见 [PLATFORMS.md](PLATFORMS.md)。 - **数据与路径**:库文件、profile、`master.key` 布局见 [RUNTIME.md](RUNTIME.md)。 - **机器可读集成**:见 [INTEGRATION.md](INTEGRATION.md)。 - **错误码**:见 [ERRORS.md](ERRORS.md)。 diff --git a/references/ERRORS.md b/references/ERRORS.md index 5c5ecb5..be34eae 100644 --- a/references/ERRORS.md +++ b/references/ERRORS.md @@ -46,7 +46,13 @@ | 前缀 | 含义 | |---|---| -| `ERROR:INVALID_PLATFORM` | 平台名无法识别 | +| `ERROR:INVALID_PLATFORM` | 平台名为空,或中文/人话名称无法对应到已注册平台 | +| `ERROR:INVALID_PLATFORM_KEY` | 键格式非法(大写、空格、中文当键等);展示名不能当键 | +| `ERROR:PLATFORM_NOT_REGISTERED` | 键格式合法但尚未 `platform ensure`;**已拒绝写入**,不会自动建平台 | +| `ERROR:AMBIGUOUS_PLATFORM` | 展示名/别名对应多个键(如「公众号」→ `wechat` 与 `wechat_mp`),请改传明确键 | +| `ERROR:PLATFORM_ALIAS_CONFLICT` | `platform ensure` 的展示名/别名已被其它平台占用 | +| `ERROR:INVALID_PLATFORM_DISPLAY_NAME` | `platform ensure` 未提供 `--display-name` | +| `ERROR:PLATFORM_DISABLED` | 平台已禁用 | | `ERROR:NO_ACCOUNTS_FOUND` | 列表无结果 | | `ERROR:ACCOUNT_NOT_FOUND` | 指定 id 不存在 | | `ERROR:ACCOUNT_DISABLED` | 账号状态为 disabled | diff --git a/references/INTEGRATION.md b/references/INTEGRATION.md index 8b847ee..9e625fb 100644 --- a/references/INTEGRATION.md +++ b/references/INTEGRATION.md @@ -4,6 +4,8 @@ 业务 skill **不能自己散落存储账号、密码、API Key、Token**。应通过 account-manager **CLI**(进程隔离)或 **CLI + `service.rpa_helpers` 同进程库**(浏览器自动化)组合获取。 +新平台必须先 `platform ensure --key <小写键> --display-name <展示名>` 再引用。`add-web` / `ensure-web` / `pick-web` **不会**根据 `--platform-display-name` 自动插入 `platforms` 行。 + ## 两种集成方式 | 方式 | 适用场景 | 调用形态 | @@ -26,6 +28,7 @@ | 目的 | 命令 | 成功时 stdout(摘要) | |---|---|---| | 枚举平台 | `platform list [--domain ...]` | `{"success": true, "data": {"platforms": [...]}}` | +| 登记平台 | `platform ensure --key <小写键> --display-name <展示名>` | `{"ok": true, "platform_key": "...", "action": "created\|updated"}` | | 平台详情 | `platform get

` | `{"success": true, "data": {...}}` | | 取单条账号 JSON | `account get ` | **单行 JSON 对象**(无固定 `success` 字段) | | 批量账号 JSON | `account list ...` | **单行 JSON 数组** | diff --git a/references/PLATFORMS.md b/references/PLATFORMS.md index dfb98ae..c03f980 100644 --- a/references/PLATFORMS.md +++ b/references/PLATFORMS.md @@ -2,7 +2,12 @@ **权威来源**:`scripts/util/platforms.py` 中的 `PLATFORMS`(当前 **31** 条)。若本文与代码不一致,**以代码为准**。 -解析函数:`resolve_platform_key(name)` — 支持英文键、`display_name`、任一 alias(大小写不敏感)。 +解析: + +- **引用**(`add-web` / `ensure-web` / `pick-web` / `platform get`):`resolve_platform_key_dynamic` — 已有键精确匹配;已有键可大小写折叠(`DeepSeek` → `deepseek`);展示名/别名仅在**唯一**对应一个键时通过。未注册键**拒绝写入**,不会自动 `INSERT`。 +- **登记**(仅 `platform ensure`):键必须符合 `^[a-z0-9][a-z0-9_-]{1,63}$` 且**本身已是小写**(`HaoKan` 非法);`--display-name` 必填;别名不得抢其它平台已占用的展示名/别名。 + +`resolve_platform_key(name)` 仍是内置表的静态别名映射(无 DB、冲突别名后写覆盖),新代码请走动态解析。 --- @@ -99,3 +104,5 @@ CLI `platform list --domain` **仅接受** `logistics | llm | content`(见 `cm ## 平台注册到数据库 `seed_platforms(conn)` 在业务流程中随 `init_db()` 触发,将 `PLATFORMS` upsert 进 `platforms` 表(仅覆盖定义字段;**不会**自动清空用户自定义的 `default_auth_strategy`,除非迁移逻辑另行更新——详见 [`SCHEMA.md`](SCHEMA.md))。 + +业务 skill 要加新平台:先 `platform ensure --key <小写键> --display-name <展示名> [--url …]`,再 `add-web` / `ensure-web`。不要把中文名或 `HaoKan` 这种大小写混写当成键;也不要指望 `--platform-display-name` 自动建行。 diff --git a/references/README.md b/references/README.md index df68994..5423a10 100644 --- a/references/README.md +++ b/references/README.md @@ -10,6 +10,7 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA ## 它能帮你做什么 +- **登记自定义平台** — `platform ensure --key douyin --display-name 抖音`;键必须是小写英文,展示名可以是中文。其它技能不能靠 add-web 自动建平台。 - **添加网页/RPA 账号** — 选平台,给出登录标识(邮箱/手机/用户名),自动创建浏览器 profile 目录。 - **添加 API Key/Token / 加密密码** — `none` / `env` / `windows_credential` / `local_encrypted`(Fernet 密文入库)。 - **查看/列出账号** — 按平台、环境、角色等多维度筛选。 @@ -58,4 +59,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA --- -**作者**:深圳匠厂科技有限公司 · **版本**:2.0.23 +**作者**:深圳匠厂科技有限公司 · **版本**:2.0.24 diff --git a/scripts/cli/app.py b/scripts/cli/app.py index a3fb229..35a8f6d 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -261,7 +261,7 @@ def main() -> None: args = p.parse_args(av[3:]) sys.exit(cmd_platform_ensure( key=args.key, - display_name=args.display_name or args.key, + display_name=args.display_name, domain=args.domain, url=args.url, auth_strategy=args.auth_strategy, diff --git a/scripts/service/_svc_common.py b/scripts/service/_svc_common.py index 464773f..3c14955 100644 --- a/scripts/service/_svc_common.py +++ b/scripts/service/_svc_common.py @@ -10,10 +10,8 @@ from typing import Optional from db.accounts_repo import ( insert_account, resolve_auth_strategy_for_platform, - ensure_platform_from_caller_meta, ) from db.auth_strategy import ( - ALL_AUTH_STRATEGIES, AUTH_STRATEGY_CLIENT_CERTIFICATE, AUTH_STRATEGY_DEVICE_BOUND, AUTH_STRATEGY_PASSWORD_PLUS_2FA, @@ -22,14 +20,10 @@ from db.auth_strategy import ( AUTH_STRATEGY_SSO_REDIRECT, ) from db.connection import get_conn, init_db -from util.logging_config import get_skill_logger from util.platforms import ( - DEFAULT_CAPABILITIES_JSON, PlatformResolveResult, _platform_list_cn_for_help, - is_valid_platform_key_format, resolve_platform_key_dynamic, - seed_platforms, ) @@ -137,7 +131,7 @@ def _remove_profile_dir(path: str) -> None: @dataclass class PlatformCallerMeta: - """调用方为未注册自定义平台提供的自动注册元数据(pick-web / add-web)。""" + """调用方可附带的平台展示信息。不再用于自动插入 platforms 行。""" display_name: Optional[str] = None url: Optional[str] = None @@ -145,8 +139,17 @@ class PlatformCallerMeta: auth_strategy: Optional[str] = None aliases: list[str] = field(default_factory=list) - def is_sufficient_for_auto_register(self) -> bool: - return bool((self.display_name or "").strip() and (self.url or "").strip()) + +_RESOLVE_CODES_WITHOUT_PLATFORM_LIST = frozenset( + { + "PLATFORM_NOT_REGISTERED", + "INVALID_PLATFORM_KEY", + "PLATFORM_DISABLED", + "AMBIGUOUS_PLATFORM", + "PLATFORM_ALIAS_CONFLICT", + "INVALID_PLATFORM_DISPLAY_NAME", + } +) def _emit_platform_resolve_error(result: PlatformResolveResult, hint: str = "") -> None: @@ -164,77 +167,9 @@ def _resolve_or_auto_register( *, hint: str = "", ) -> Optional[str]: - """解析平台;未注册时可在元数据充足时自动 ensure(幂等、仅填空字段)。""" - init_db() - conn = get_conn() - try: - result = resolve_platform_key_dynamic(platform_input, conn=conn) - if result.ok: - return result.platform_key - - if result.error_code in ("PLATFORM_DISABLED", "INVALID_PLATFORM_KEY", "INVALID_PLATFORM"): - _emit_platform_resolve_error(result, hint=hint) - return None - - if result.error_code != "PLATFORM_NOT_REGISTERED": - _emit_platform_resolve_error(result, hint=hint) - if result.error_code not in ( - "PLATFORM_NOT_REGISTERED", - "INVALID_PLATFORM_KEY", - "PLATFORM_DISABLED", - ): - _say("支持:" + _platform_list_cn_for_help()) - return None - - raw_key = (platform_input or "").strip().lower() - if not is_valid_platform_key_format(raw_key): - _emit_platform_resolve_error( - PlatformResolveResult( - error_code="INVALID_PLATFORM_KEY", - message=result.message or f"平台 key 格式非法:「{platform_input}」。", - ), - hint=hint, - ) - return None - - if not meta or not meta.is_sufficient_for_auto_register(): - _emit_platform_resolve_error( - PlatformResolveResult( - error_code="PLATFORM_NOT_REGISTERED", - message=( - f"平台 {raw_key} 尚未注册。" - "请传入 --platform-display-name 与 --platform-url," - "或先执行 account platform ensure。" - ), - ), - hint=hint, - ) - return None - - if meta.auth_strategy and meta.auth_strategy not in ALL_AUTH_STRATEGIES: - _say(_err_json( - "ERR_AUTH_STRATEGY_NOT_SUPPORTED", - f"不支持的 --platform-auth-strategy:{meta.auth_strategy}", - )) - return None - - seed_platforms(conn) - action = ensure_platform_from_caller_meta( - conn, - raw_key, - display_name=(meta.display_name or "").strip(), - domain=(meta.domain or "generic").strip() or "generic", - default_url=(meta.url or "").strip(), - aliases=meta.aliases, - default_auth_strategy=meta.auth_strategy, - capabilities_json=DEFAULT_CAPABILITIES_JSON, - ) - get_skill_logger().info( - "platform_auto_registered key=%s action=%s", raw_key, action - ) - return raw_key - finally: - conn.close() + """解析已注册平台;未注册则拒绝。meta 仅兼容旧调用方,不再自动建平台。""" + del meta + return _resolve_or_fail(platform_input, hint=hint) def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: @@ -250,6 +185,6 @@ def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: return result.platform_key _emit_platform_resolve_error(result, hint=hint) - if result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"): + if result.error_code not in _RESOLVE_CODES_WITHOUT_PLATFORM_LIST: _say("支持:" + _platform_list_cn_for_help()) return None diff --git a/scripts/service/platform_commands.py b/scripts/service/platform_commands.py index 2916f63..7f01c66 100644 --- a/scripts/service/platform_commands.py +++ b/scripts/service/platform_commands.py @@ -8,7 +8,7 @@ from db.connection import get_conn, init_db from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger -from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms +from util.platforms import DEFAULT_CAPABILITIES_JSON, seed_platforms from util.runtime_paths import get_db_path @@ -76,6 +76,20 @@ def cmd_platform_get(input_key: str) -> None: )) +def _aliases_for_ensure(aliases: Optional[str], key: str, display_name: str) -> tuple[list[str], str]: + from util.platforms import _parse_aliases_json + + claimed: list[str] = [] + extra = _parse_aliases_json(aliases) if aliases else [] + for item in (display_name, key, *extra): + text = (item or "").strip() + if text and text not in claimed: + claimed.append(text) + if aliases: + return claimed, aliases + return claimed, json.dumps(claimed, ensure_ascii=False) + + def cmd_platform_ensure( key: str, display_name: str, @@ -89,25 +103,33 @@ def cmd_platform_ensure( 幂等地注册或更新平台到 DB。 输出单行 JSON:{"ok": true, "platform_key": "xxx", "action": "created|updated"} """ + from util.platform_validate import validate_platform_registration + log = get_skill_logger() key = (key or "").strip() - display_name = (display_name or key).strip() + display_name = (display_name or "").strip() if not key: _say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数') return 1 - if not is_valid_platform_key_format(key): - _say(_err_json( - "ERROR:INVALID_PLATFORM_KEY", - f"平台 key 格式非法:「{key}」。建议使用小写字母、数字、下划线或连字符。", - )) - return 1 - - aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False) - capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON init_db() conn = get_conn() try: + alias_list, aliases_json = _aliases_for_ensure(aliases, key, display_name) + check = validate_platform_registration( + key, + display_name, + aliases=alias_list, + conn=conn, + ) + if not check.ok: + _say(_err_json( + f"ERROR:{check.error_code}", + check.message or "平台登记校验失败。", + )) + return 1 + + capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON existed = bool( conn.execute( "SELECT 1 FROM platforms WHERE platform_key = ?", (key,) diff --git a/scripts/util/platform_validate.py b/scripts/util/platform_validate.py new file mode 100644 index 0000000..c221b93 --- /dev/null +++ b/scripts/util/platform_validate.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""平台键 / 展示名校验:引用已有平台 vs 登记新平台。""" +from __future__ import annotations + +import re +from typing import Any, Iterable, Optional + +from util.platforms import ( + PLATFORMS, + PlatformResolveResult, + _parse_aliases_json, + is_valid_platform_key_format, +) + +_ASCII_TOKEN = re.compile(r"[A-Za-z0-9]") + + +def msg_empty() -> str: + return "平台名不能为空。请传平台键(如 douyin、deepseek),不要传空字符串。" + + +def msg_invalid_key(raw: str) -> str: + return ( + f"平台键不合法:「{raw}」。\n" + "平台键必须是小写英文/数字/下划线或连字符,例如 douyin、deepseek。\n" + "「抖音」「DeepSeek」是展示名,不能当键。请改成已有键,或先登记:\n" + "platform ensure --key <键> --display-name <中文名>\n" + "查看已有键:platform list" + ) + + +def msg_unrecognized(raw: str) -> str: + return ( + f"无法识别平台:「{raw}」。\n" + "请传平台键(如 douyin),不要自创中文键。\n" + "先执行 platform list 核对;若是新平台,用 " + "platform ensure --key <键> --display-name <中文名> 登记后再调 add-web / ensure-web。" + ) + + +def msg_not_registered(key: str) -> str: + return ( + f"平台 `{key}` 尚未注册,已拒绝写入,以免产生不规范平台。\n" + f"若确认是新平台:platform ensure --key {key} --display-name <中文名> [--url …]\n" + "若其实是已有站:请改用 platform list 里的平台键。" + ) + + +def msg_ambiguous(raw: str, keys: Iterable[str]) -> str: + listed = "、".join(f"`{k}`" for k in keys) + return f"平台「{raw}」对应多个键({listed}),请改传明确的平台键。" + + +def msg_alias_conflict(alias: str, owner: str, key: str) -> str: + return ( + f"别名「{alias}」已被平台 `{owner}` 占用,不能再登记到 `{key}`。" + "请换展示名/别名,或复用已有平台键。" + ) + + +def msg_display_name_required() -> str: + return ( + "登记平台必须同时提供 --display-name(给人看的名称,可以是中文)。\n" + "平台键与展示名要分开,例如 --key douyin --display-name 抖音。" + ) + + +def msg_disabled(key: str) -> str: + return f"平台 {key} 已禁用。" + + +def _looks_like_attempted_key(raw: str) -> bool: + return bool(_ASCII_TOKEN.search(raw or "")) and not is_valid_platform_key_format(raw) + + +def _lookup_stored_key(conn, key_lower: str) -> Optional[tuple[str, int]]: + """Return (platform_key, enabled) for an exact key, builtin or DB.""" + needle = (key_lower or "").strip().lower() + if not needle: + return None + if conn is not None: + row = conn.execute( + "SELECT platform_key, enabled FROM platforms WHERE lower(platform_key) = ?", + (needle,), + ).fetchone() + if row: + return str(row[0]), int(row[1] or 0) + if needle in PLATFORMS: + return needle, 1 + return None + + +def _iter_enabled_name_map(conn) -> list[tuple[str, set[str]]]: + """Each enabled platform with its display_name + aliases (not the key).""" + out: list[tuple[str, set[str]]] = [] + seen: set[str] = set() + if conn is not None: + rows = conn.execute( + "SELECT platform_key, enabled, display_name, aliases_json FROM platforms" + ).fetchall() + for platform_key, enabled, display_name, aliases_json in rows: + key = str(platform_key) + seen.add(key) + if not enabled: + continue + names: set[str] = set() + if display_name: + names.add(str(display_name).strip()) + for alias in _parse_aliases_json(aliases_json): + names.add(alias) + out.append((key, {n for n in names if n})) + for key, spec in PLATFORMS.items(): + if key in seen: + continue + names = {str(spec.get("display_name") or "").strip()} + for alias in spec.get("aliases") or []: + names.add(str(alias).strip()) + out.append((key, {n for n in names if n})) + return out + + +def _alias_matches(raw: str, conn) -> list[str]: + needle = (raw or "").strip() + if not needle: + return [] + lower = needle.lower() + matches: list[str] = [] + for key, names in _iter_enabled_name_map(conn): + lowered = {n.lower() for n in names} + if needle in names or lower in lowered: + if key not in matches: + matches.append(key) + return matches + + +def _keys_claiming_label(label: str, conn) -> list[str]: + """Keys that already use this string as key, display_name, or alias.""" + text = (label or "").strip() + if not text: + return [] + owners: list[str] = [] + stored = _lookup_stored_key(conn, text.lower()) + if stored and stored[0] not in owners: + owners.append(stored[0]) + for key in _alias_matches(text, conn): + if key not in owners: + owners.append(key) + return owners + + +def resolve_platform_ref(input_name: str, conn=None) -> PlatformResolveResult: + """引用已有平台:已有键优先,其次唯一展示名/别名。不创建平台行。""" + raw = (input_name or "").strip() + if not raw: + return PlatformResolveResult(error_code="INVALID_PLATFORM", message=msg_empty()) + + close_conn = False + if conn is None: + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + close_conn = True + try: + if is_valid_platform_key_format(raw): + stored = _lookup_stored_key(conn, raw) + if stored: + key, enabled = stored + if not enabled: + return PlatformResolveResult( + error_code="PLATFORM_DISABLED", + message=msg_disabled(key), + ) + return PlatformResolveResult(platform_key=key) + + folded = raw.lower() + if is_valid_platform_key_format(folded): + stored = _lookup_stored_key(conn, folded) + if stored: + key, enabled = stored + if not enabled: + return PlatformResolveResult( + error_code="PLATFORM_DISABLED", + message=msg_disabled(key), + ) + return PlatformResolveResult(platform_key=key) + + matches = _alias_matches(raw, conn) + if len(matches) == 1: + return PlatformResolveResult(platform_key=matches[0]) + if len(matches) > 1: + return PlatformResolveResult( + error_code="AMBIGUOUS_PLATFORM", + message=msg_ambiguous(raw, matches), + ) + + if is_valid_platform_key_format(raw): + return PlatformResolveResult( + error_code="PLATFORM_NOT_REGISTERED", + message=msg_not_registered(raw), + ) + if _looks_like_attempted_key(raw) or is_valid_platform_key_format(folded): + return PlatformResolveResult( + error_code="INVALID_PLATFORM_KEY", + message=msg_invalid_key(raw), + ) + return PlatformResolveResult( + error_code="INVALID_PLATFORM", + message=msg_unrecognized(raw), + ) + finally: + if close_conn: + conn.close() + + +def validate_platform_registration( + key: str, + display_name: str, + aliases: Optional[Iterable[Any]] = None, + conn=None, +) -> PlatformResolveResult: + """登记/更新平台:键必须已是规范小写;展示名必填;别名不得抢别人的。""" + raw_key = (key or "").strip() + if not raw_key: + return PlatformResolveResult(error_code="INVALID_PLATFORM", message=msg_empty()) + if not is_valid_platform_key_format(raw_key): + return PlatformResolveResult( + error_code="INVALID_PLATFORM_KEY", + message=msg_invalid_key(raw_key), + ) + name = (display_name or "").strip() + if not name: + return PlatformResolveResult( + error_code="INVALID_PLATFORM_DISPLAY_NAME", + message=msg_display_name_required(), + ) + + close_conn = False + if conn is None: + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + close_conn = True + try: + claimed = [name] + if aliases: + for item in aliases: + text = str(item).strip() + if text: + claimed.append(text) + for label in claimed: + if label.lower() == raw_key: + continue + owners = [k for k in _keys_claiming_label(label, conn) if k != raw_key] + if owners: + return PlatformResolveResult( + error_code="PLATFORM_ALIAS_CONFLICT", + message=msg_alias_conflict(label, owners[0], raw_key), + ) + return PlatformResolveResult(platform_key=raw_key) + finally: + if close_conn: + conn.close() diff --git a/scripts/util/platforms.py b/scripts/util/platforms.py index 92023f5..2df8853 100644 --- a/scripts/util/platforms.py +++ b/scripts/util/platforms.py @@ -337,8 +337,8 @@ def resolve_platform_key(name: str) -> Optional[str]: def is_valid_platform_key_format(name: str) -> bool: - """平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$""" - s = (name or "").strip().lower() + """平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$(不大小写折叠;登记必须本身已是小写)。""" + s = (name or "").strip() if not s: return False return bool(PLATFORM_KEY_PATTERN.match(s)) @@ -367,87 +367,11 @@ class PlatformResolveResult: return bool(self.platform_key) and not self.error_code -def _resolve_platform_from_db(conn, raw: str) -> PlatformResolveResult: - """在 platforms 表中解析:精确 key(enabled=1)与 aliases_json。""" - raw_stripped = (raw or "").strip() - raw_lower = raw_stripped.lower() - - if is_valid_platform_key_format(raw_stripped): - row = conn.execute( - "SELECT platform_key, enabled FROM platforms WHERE platform_key = ?", - (raw_lower,), - ).fetchone() - if row: - if not row[1]: - return PlatformResolveResult( - error_code="PLATFORM_DISABLED", - message=f"平台 {row[0]} 已禁用。", - ) - return PlatformResolveResult(platform_key=row[0]) - - rows = conn.execute( - "SELECT platform_key, enabled, aliases_json FROM platforms" - ).fetchall() - for platform_key, enabled, aliases_json in rows: - if not enabled: - continue - names = {platform_key.lower()} - for alias in _parse_aliases_json(aliases_json): - names.add(alias.lower()) - names.add(alias) - if raw_lower in names or raw_stripped in names: - return PlatformResolveResult(platform_key=platform_key) - - return PlatformResolveResult() - - def resolve_platform_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult: - """ - 统一平台解析:内置 PLATFORMS → DB platforms 表 → 错误分类。 - """ - raw = (input_name or "").strip() - if not raw: - return PlatformResolveResult( - error_code="INVALID_PLATFORM", - message="平台名不能为空。", - ) + """引用已有平台(键 / 唯一展示名或别名)。实现见 util.platform_validate。""" + from util.platform_validate import resolve_platform_ref - builtin = resolve_platform_key(raw) - if builtin: - return PlatformResolveResult(platform_key=builtin) - - close_conn = False - if conn is None: - from db.connection import get_conn, init_db - - init_db() - conn = get_conn() - close_conn = True - try: - db_result = _resolve_platform_from_db(conn, raw) - if db_result.ok or db_result.error_code: - return db_result - finally: - if close_conn: - conn.close() - - if not is_valid_platform_key_format(raw): - return PlatformResolveResult( - error_code="INVALID_PLATFORM_KEY", - message=( - f"平台 key 格式非法:「{raw}」。" - "建议使用小写字母、数字、下划线或连字符,例如 jiangchang。" - ), - ) - - key_hint = raw.lower() - return PlatformResolveResult( - error_code="PLATFORM_NOT_REGISTERED", - message=( - f"平台 {key_hint} 尚未注册,请先执行 " - f"account platform ensure --key {key_hint} --display-name <名称> [--url ]" - ), - ) + return resolve_platform_ref(input_name, conn) def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]: diff --git a/tests/test_custom_platforms.py b/tests/test_custom_platforms.py index 5d91b77..4fca412 100644 --- a/tests/test_custom_platforms.py +++ b/tests/test_custom_platforms.py @@ -56,7 +56,7 @@ class TestDynamicPlatformResolution: result = resolve_platform_key_dynamic("江厂 空格", conn=clean_db) assert not result.ok - assert result.error_code == "INVALID_PLATFORM_KEY" + assert result.error_code == "INVALID_PLATFORM" def test_empty_platform(self, clean_db): from util.platforms import resolve_platform_key_dynamic @@ -97,6 +97,43 @@ class TestPlatformEnsure: assert payload["platform_key"] == "jiangchang" assert payload["action"] in ("created", "updated") + def test_ensure_requires_display_name(self, clean_db, capsys): + from service.account_service import cmd_platform_ensure + + rc = cmd_platform_ensure(key="hao_kan", display_name="") + assert rc == 1 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_DISPLAY_NAME" + count = clean_db.execute( + "SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'" + ).fetchone()[0] + assert count == 0 + + def test_ensure_rejects_mixed_case_key(self, clean_db, capsys): + from service.account_service import cmd_platform_ensure + + rc = cmd_platform_ensure(key="HaoKan", display_name="好看") + assert rc == 1 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY" + count = clean_db.execute( + "SELECT COUNT(*) FROM platforms WHERE lower(platform_key) = 'haokan'" + ).fetchone()[0] + assert count == 0 + + def test_ensure_rejects_alias_owned_by_another_platform(self, clean_db, capsys): + from service.account_service import cmd_platform_ensure + + rc = cmd_platform_ensure(key="hao_kan", display_name="抖音") + assert rc == 1 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["error"]["code"] == "ERROR:PLATFORM_ALIAS_CONFLICT" + assert "douyin" in payload["error"]["message"] + count = clean_db.execute( + "SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'" + ).fetchone()[0] + assert count == 0 + class TestCustomPlatformAccounts: def test_add_web_after_ensure(self, clean_db): @@ -161,7 +198,8 @@ class TestCustomPlatformAccounts: payload = json.loads(out) assert payload["success"] is False assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED" - assert "--platform-display-name" in payload["error"]["message"] + assert "platform ensure" in payload["error"]["message"] + assert "--platform-display-name" not in payload["error"]["message"] def test_add_web_unregistered_returns_structured_error(self, clean_db, capsys): from service.account_service import cmd_account_add_web @@ -255,7 +293,7 @@ JIANGCHANG_META_KWARGS = dict( ) -class TestAutoRegisterFromCallerMeta: +class TestCallerMetaDoesNotAutoRegister: def _meta(self): from service.account_service import PlatformCallerMeta @@ -293,9 +331,10 @@ class TestAutoRegisterFromCallerMeta: payload = json.loads(out) assert payload["success"] is False assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED" - assert "--platform-display-name" in payload["error"]["message"] + assert "platform ensure" in payload["error"]["message"] + assert "--platform-display-name" not in payload["error"]["message"] - def test_pick_web_unregistered_with_meta_auto_registers(self, clean_db, capsys): + def test_pick_web_unregistered_with_meta_does_not_insert(self, clean_db, capsys): from service.account_service import cmd_account_pick_web, PlatformCallerMeta from util.platforms import resolve_platform_key_dynamic @@ -310,28 +349,33 @@ class TestAutoRegisterFromCallerMeta: out = capsys.readouterr().out.strip() payload = json.loads(out) assert payload["success"] is False - assert payload["error"]["code"] == "ERROR:NO_ACCOUNT" + assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED" + assert "已拒绝写入" in payload["error"]["message"] result = resolve_platform_key_dynamic("auto_reg_plat", conn=clean_db) - assert result.ok - assert result.platform_key == "auto_reg_plat" + assert not result.ok + assert result.error_code == "PLATFORM_NOT_REGISTERED" + count = clean_db.execute( + "SELECT COUNT(*) FROM platforms WHERE platform_key = 'auto_reg_plat'" + ).fetchone()[0] + assert count == 0 - def test_add_web_unregistered_with_meta_auto_registers(self, clean_db): + def test_add_web_unregistered_with_meta_does_not_insert(self, clean_db, capsys): from service.account_service import cmd_account_add_web, PlatformCallerMeta from db.accounts_repo import find_accounts meta = PlatformCallerMeta( - display_name="匠厂后台", + display_name="未登记测试", url=JIANGCHANG_URL, - domain="jc2009.com", + domain="example.com", auth_strategy="qr_code_manual", - aliases=["jiangchang"], + aliases=["unreg_meta_plat"], ) cmd_account_add_web( - platform_input="jiangchang", + platform_input="unreg_meta_plat", login_id="manual", login_id_type="manual", - label="匠厂后台", + label="未登记", tenant_id=None, environment="production", role="admin", @@ -341,9 +385,15 @@ class TestAutoRegisterFromCallerMeta: profile_dir_override=None, platform_meta=meta, ) - accs = find_accounts(platform_key="jiangchang") - assert len(accs) == 1 - assert accs[0]["url"] == JIANGCHANG_URL + out = capsys.readouterr().out.strip() + payload = json.loads(out.splitlines()[0]) + assert payload["success"] is False + assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED" + assert find_accounts(platform_key="unreg_meta_plat") == [] + count = clean_db.execute( + "SELECT COUNT(*) FROM platforms WHERE platform_key = 'unreg_meta_plat'" + ).fetchone()[0] + assert count == 0 def test_invalid_platform_key_no_register(self, clean_db, capsys): from service.account_service import cmd_account_pick_web, PlatformCallerMeta @@ -382,7 +432,7 @@ class TestAutoRegisterFromCallerMeta: ).fetchone()[0] assert enabled == 0 - def test_auto_register_idempotent(self, clean_db): + def test_caller_meta_does_not_insert_idempotent(self, clean_db, capsys): from service.account_service import cmd_account_pick_web, PlatformCallerMeta meta = PlatformCallerMeta( @@ -395,4 +445,7 @@ class TestAutoRegisterFromCallerMeta: count = clean_db.execute( "SELECT COUNT(*) FROM platforms WHERE platform_key = 'idem_plat'" ).fetchone()[0] - assert count == 1 + assert count == 0 + out = capsys.readouterr().out.strip().splitlines()[-1] + payload = json.loads(out) + assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED" diff --git a/tests/test_platform_dynamic.py b/tests/test_platform_dynamic.py index 4a2308a..8657ba5 100644 --- a/tests/test_platform_dynamic.py +++ b/tests/test_platform_dynamic.py @@ -74,3 +74,60 @@ class TestResolvePlatformKeyDynamicParsing: clean_db.commit() result = resolve_platform_key_dynamic("bad_alias", conn=clean_db) assert result.platform_key == "bad_alias" + + +class TestPlatformKeyFormatAndAmbiguity: + def test_format_rejects_mixed_case(self): + from util.platforms import is_valid_platform_key_format + + assert is_valid_platform_key_format("haokan") is True + assert is_valid_platform_key_format("HaoKan") is False + assert is_valid_platform_key_format("hao_kan") is True + + def test_deepseek_display_resolves_to_key(self, clean_db): + from util.platforms import resolve_platform_key_dynamic + + result = resolve_platform_key_dynamic("DeepSeek", conn=clean_db) + assert result.ok + assert result.platform_key == "deepseek" + + def test_douyin_alias_still_unique(self, clean_db): + from util.platforms import resolve_platform_key_dynamic + + result = resolve_platform_key_dynamic("抖音", conn=clean_db) + assert result.platform_key == "douyin" + + def test_wechat_key_wins(self, clean_db): + from util.platforms import resolve_platform_key_dynamic + + result = resolve_platform_key_dynamic("wechat", conn=clean_db) + assert result.ok + assert result.platform_key == "wechat" + + def test_gongzhonghao_is_ambiguous(self, clean_db): + from util.platforms import resolve_platform_key_dynamic + + result = resolve_platform_key_dynamic("公众号", conn=clean_db) + assert not result.ok + assert result.error_code == "AMBIGUOUS_PLATFORM" + assert "wechat" in (result.message or "") + assert "wechat_mp" in (result.message or "") + + def test_unregistered_lookalike_key(self, clean_db): + from util.platforms import resolve_platform_key_dynamic + + result = resolve_platform_key_dynamic("HaoKan", conn=clean_db) + assert result.error_code == "INVALID_PLATFORM_KEY" + assert "platform ensure" in (result.message or "") + + def test_unregistered_valid_key_not_inserted(self, clean_db): + from util.platforms import resolve_platform_key_dynamic + + result = resolve_platform_key_dynamic("hao_kan", conn=clean_db) + assert result.error_code == "PLATFORM_NOT_REGISTERED" + assert "已拒绝写入" in (result.message or "") + count = clean_db.execute( + "SELECT COUNT(*) FROM platforms WHERE platform_key = 'hao_kan'" + ).fetchone()[0] + assert count == 0 + diff --git a/tests/test_row_open_actions.py b/tests/test_row_open_actions.py index 3edff21..fb13c69 100644 --- a/tests/test_row_open_actions.py +++ b/tests/test_row_open_actions.py @@ -39,6 +39,18 @@ class TestRowOpenActions(unittest.TestCase): self.assertFalse(reveal.get("replaces")) self.assertFalse(open_browser.get("replaces")) + def test_platform_create_takes_over_host_add(self) -> None: + create = _by_id(_load_actions(), "platform-ensure") + self.assertEqual(create.get("replaces"), "create") + self.assertEqual(create.get("placements") or [], []) + self.assertEqual(create.get("label"), "新增平台") + self.assertEqual((create.get("bind") or {}).get("tables"), ["platforms"]) + props = (create.get("inputSchema") or {}).get("properties") or {} + self.assertEqual(set(props), {"platform_key", "display_name", "default_url"}) + args = (create.get("entrypoint") or {}).get("args") or [] + self.assertIn("--key", args) + self.assertIn("--display-name", args) + def test_create_takes_over_host_add_without_row_duplicate(self) -> None: create = _by_id(_load_actions(), "account-add-web") self.assertEqual(create.get("replaces"), "create")