582 lines
20 KiB
Python
582 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Platform definitions and resolution.
|
||
|
||
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
|
||
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
|
||
"""
|
||
import json
|
||
import re
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Any, Optional
|
||
|
||
from util.logging_config import get_skill_logger
|
||
|
||
PLATFORM_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
|
||
|
||
DEFAULT_CAPABILITIES_JSON = '{"web": true, "api_key": false, "rpa": true}'
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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},
|
||
},
|
||
"cosco": {
|
||
"display_name": "COSCO",
|
||
"domain": "logistics",
|
||
"provider_code": "cosco",
|
||
"default_url": "",
|
||
"aliases": ["中远海运", "COSCO", "cosco"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||
},
|
||
"msc": {
|
||
"display_name": "MSC",
|
||
"domain": "logistics",
|
||
"provider_code": "msc",
|
||
"default_url": "",
|
||
"aliases": ["MSC", "地中海航运"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||
},
|
||
"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},
|
||
},
|
||
"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": {
|
||
"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": {
|
||
"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": {
|
||
"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": {
|
||
"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": {
|
||
"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": {
|
||
"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},
|
||
},
|
||
# =========================================================================
|
||
# Aligned with db.platform_defaults.PLATFORM_DEFAULT_AUTH_STRATEGY keys
|
||
# =========================================================================
|
||
"douyin": {
|
||
"display_name": "抖音",
|
||
"domain": "content",
|
||
"provider_code": "douyin",
|
||
"default_url": "https://www.douyin.com",
|
||
"aliases": ["抖音", "Douyin"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"wechat_mp": {
|
||
"display_name": "微信公众号",
|
||
"domain": "content",
|
||
"provider_code": "wechat_mp",
|
||
"default_url": "https://mp.weixin.qq.com",
|
||
"aliases": ["微信公众号", "公众号", "WeChat MP"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"xiaohongshu": {
|
||
"display_name": "小红书",
|
||
"domain": "content",
|
||
"provider_code": "xiaohongshu",
|
||
"default_url": "https://www.xiaohongshu.com",
|
||
"aliases": ["小红书", "Xiaohongshu", "RED"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"bilibili": {
|
||
"display_name": "哔哩哔哩",
|
||
"domain": "content",
|
||
"provider_code": "bilibili",
|
||
"default_url": "https://www.bilibili.com",
|
||
"aliases": ["B站", "哔哩哔哩", "Bilibili"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"github": {
|
||
"display_name": "GitHub",
|
||
"domain": "dev",
|
||
"provider_code": "github",
|
||
"default_url": "https://github.com/login",
|
||
"aliases": ["GitHub", "github"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"aws": {
|
||
"display_name": "AWS",
|
||
"domain": "dev",
|
||
"provider_code": "aws",
|
||
"default_url": "https://console.aws.amazon.com",
|
||
"aliases": ["AWS", "Amazon Web Services", "亚马逊云"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"gcp": {
|
||
"display_name": "GCP",
|
||
"domain": "dev",
|
||
"provider_code": "gcp",
|
||
"default_url": "https://console.cloud.google.com",
|
||
"aliases": ["GCP", "Google Cloud", "谷歌云"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"icbc_sim": {
|
||
"display_name": "工行仿真",
|
||
"domain": "sim",
|
||
"provider_code": "icbc_sim",
|
||
"default_url": "http://localhost:5180",
|
||
"aliases": ["工行仿真", "ICBC Simulator", "ICBC SIM"],
|
||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||
},
|
||
"openai": {
|
||
"display_name": "OpenAI API",
|
||
"domain": "llm",
|
||
"provider_code": "openai",
|
||
"default_url": "https://platform.openai.com",
|
||
"aliases": ["OpenAI", "openai", "GPT", "ChatGPT API"],
|
||
"capabilities": {"web": False, "api_key": True, "rpa": False},
|
||
},
|
||
"anthropic": {
|
||
"display_name": "Anthropic",
|
||
"domain": "llm",
|
||
"provider_code": "anthropic",
|
||
"default_url": "https://console.anthropic.com",
|
||
"aliases": ["Anthropic", "anthropic", "Claude API"],
|
||
"capabilities": {"web": False, "api_key": True, "rpa": False},
|
||
},
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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():
|
||
# 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_ALIAS_MAP = _build_alias_map()
|
||
|
||
|
||
def resolve_platform_key(name: str) -> Optional[str]:
|
||
"""
|
||
Resolve user input to a canonical platform key (built-in PLATFORMS only).
|
||
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()
|
||
# Direct key match
|
||
if sl in PLATFORMS:
|
||
return sl
|
||
# 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 is_valid_platform_key_format(name: str) -> bool:
|
||
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$"""
|
||
s = (name or "").strip().lower()
|
||
if not s:
|
||
return False
|
||
return bool(PLATFORM_KEY_PATTERN.match(s))
|
||
|
||
|
||
def _parse_aliases_json(val: Any) -> list[str]:
|
||
if val is None:
|
||
return []
|
||
try:
|
||
data = json.loads(val) if isinstance(val, str) else val
|
||
if isinstance(data, list):
|
||
return [str(a).strip() for a in data if str(a).strip()]
|
||
except (json.JSONDecodeError, TypeError, ValueError):
|
||
pass
|
||
return []
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PlatformResolveResult:
|
||
platform_key: Optional[str] = None
|
||
error_code: Optional[str] = None
|
||
message: Optional[str] = None
|
||
|
||
@property
|
||
def ok(self) -> bool:
|
||
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="平台名不能为空。",
|
||
)
|
||
|
||
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 <url>]"
|
||
),
|
||
)
|
||
|
||
|
||
def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]:
|
||
"""内置 PLATFORMS 优先结构;自定义平台从 DB platforms 表读取。"""
|
||
spec = PLATFORMS.get(platform_key)
|
||
if spec:
|
||
return dict(spec)
|
||
|
||
close_conn = False
|
||
if conn is None:
|
||
from db.connection import get_conn, init_db
|
||
|
||
init_db()
|
||
conn = get_conn()
|
||
close_conn = True
|
||
try:
|
||
from db.accounts_repo import get_platform_from_db
|
||
|
||
plat = get_platform_from_db(conn, platform_key)
|
||
if plat:
|
||
return {
|
||
"display_name": plat["display_name"],
|
||
"domain": plat.get("domain") or "generic",
|
||
"provider_code": plat.get("provider_code") or platform_key,
|
||
"default_url": plat.get("default_url") or "",
|
||
"aliases": plat.get("aliases") or [],
|
||
"capabilities": plat.get("capabilities") or {},
|
||
}
|
||
finally:
|
||
if close_conn:
|
||
conn.close()
|
||
return {}
|
||
|
||
|
||
def get_platform(key: str) -> Optional[dict[str, Any]]:
|
||
"""Return the full platform spec dict for a validated built-in key."""
|
||
return PLATFORMS.get(key)
|
||
|
||
|
||
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.
|
||
"""
|
||
from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY
|
||
|
||
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)
|
||
default_auth_strategy = PLATFORM_DEFAULT_AUTH_STRATEGY.get(key)
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||
default_url, aliases_json, capabilities_json,
|
||
enabled, default_auth_strategy, 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,
|
||
default_auth_strategy = excluded.default_auth_strategy,
|
||
updated_at = excluded.updated_at
|
||
""",
|
||
(
|
||
key,
|
||
spec["display_name"],
|
||
spec["domain"],
|
||
spec.get("provider_code"),
|
||
spec.get("default_url", ""),
|
||
aliases_json,
|
||
capabilities_json,
|
||
default_auth_strategy,
|
||
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:
|
||
"""帮助文案:一行展示名,供 CLI 用法提示。"""
|
||
return "、".join(
|
||
spec["display_name"] for _k, spec in sorted(
|
||
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
|
||
)
|
||
)
|