ForcePilot/backend/package/yuxi/channel/config.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

361 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""渠道配置管理器"""
import json
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.channel.constants import CHANNEL_CONFIG_CHANGE_CHANNEL
from yuxi.channel.plugins.registry import get_registry
from yuxi.channel.routing.cache import RouteCache, get_default_route_cache
from yuxi.services.run_queue_service import get_redis_client
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.model_channel import ChannelConfig
from yuxi.utils.logging_config import logger
CONFIG_CHANGE_CHANNEL = CHANNEL_CONFIG_CHANGE_CHANNEL
class ChannelConfigAlreadyExistsError(ValueError):
pass
class ChannelConfigManager:
"""管理渠道账户配置,为生命周期管理器提供启用账户与配置读取能力。"""
def __init__(self, route_cache: RouteCache | None = None) -> None:
self._route_cache = route_cache or get_default_route_cache()
async def _publish_config_change(
self,
channel_type: str,
account_id: str,
action: str,
) -> None:
try:
redis = await get_redis_client()
await redis.publish(
CONFIG_CHANGE_CHANNEL,
json.dumps(
{"channel_type": channel_type, "account_id": account_id, "action": action},
ensure_ascii=False,
),
)
except Exception as exc:
logger.warning("Failed to publish channel config change: %s", exc)
async def _invalidate_local_cache(self, channel_type: str, account_id: str) -> None:
if self._route_cache is not None:
await self._route_cache.invalidate_by_account(channel_type, account_id)
async def _notify_config_changed(
self,
channel_type: str,
account_id: str,
action: str,
) -> None:
await self._invalidate_local_cache(channel_type, account_id)
await self._publish_config_change(channel_type, account_id, action)
async def notify_config_changed(
self,
channel_type: str,
account_id: str,
action: str,
) -> None:
await self._notify_config_changed(channel_type, account_id, action)
def validate_config_json(self, channel_type: str, config_json: dict) -> None:
"""根据插件 config_schema 的 required 字段及 properties 类型校验配置。"""
registry = get_registry()
plugin = registry.get_plugin(channel_type)
if plugin is None:
raise ValueError(f"Unknown channel type: {channel_type}")
schema = plugin.get_meta().config_schema or {}
required = schema.get("required", [])
missing = [key for key in required if key not in config_json]
if missing:
raise ValueError(f"Missing required config fields for {channel_type}: {', '.join(missing)}")
properties = schema.get("properties", {})
type_errors: list[str] = []
for key, value in config_json.items():
prop_schema = properties.get(key)
if not isinstance(prop_schema, dict):
continue
expected_type = prop_schema.get("type")
if expected_type is None:
continue
if expected_type == "string" and not isinstance(value, str):
type_errors.append(f"{key} must be a string")
elif expected_type == "integer" and (not isinstance(value, int) or isinstance(value, bool)):
type_errors.append(f"{key} must be an integer")
elif expected_type == "number" and (not isinstance(value, (int, float)) or isinstance(value, bool)):
type_errors.append(f"{key} must be a number")
elif expected_type == "boolean" and not isinstance(value, bool):
type_errors.append(f"{key} must be a boolean")
elif expected_type == "array" and not isinstance(value, list):
type_errors.append(f"{key} must be an array")
elif expected_type == "object" and not isinstance(value, dict):
type_errors.append(f"{key} must be an object")
for key in ("inbound_middlewares", "outbound_middlewares", "security_checkers"):
if key in config_json:
try:
self._validate_middleware_config(key, config_json[key])
except ValueError as exc:
type_errors.append(str(exc))
if type_errors:
raise ValueError(f"Invalid config types for {channel_type}: {'; '.join(type_errors)}")
def _validate_middleware_config(self, key: str, value: list) -> None:
"""校验中间件/安全策略配置数组的格式。
每项必须包含 ``name``(字符串)和 ``enabled``(布尔值);
可选 ``order`` / ``priority`` 必须是整数;可选 ``config`` 必须是对象。
"""
if not isinstance(value, list):
raise ValueError(f"{key} must be an array")
for idx, entry in enumerate(value):
if not isinstance(entry, dict):
raise ValueError(f"{key}[{idx}] must be an object")
if "name" not in entry:
raise ValueError(f"{key}[{idx}] missing required field 'name'")
if not isinstance(entry["name"], str):
raise ValueError(f"{key}[{idx}].name must be a string")
if "enabled" not in entry:
raise ValueError(f"{key}[{idx}] missing required field 'enabled'")
if not isinstance(entry["enabled"], bool):
raise ValueError(f"{key}[{idx}].enabled must be a boolean")
order_or_priority = entry.get("order") if key != "security_checkers" else entry.get("priority")
if order_or_priority is not None and not isinstance(order_or_priority, int):
raise ValueError(f"{key}[{idx}] order/priority must be an integer")
config = entry.get("config")
if config is not None and not isinstance(config, dict):
raise ValueError(f"{key}[{idx}].config must be an object")
async def list_enabled_accounts(self) -> list[dict]:
"""返回所有已启用账户的运行时配置列表。"""
async with pg_manager.get_async_session_context() as session:
result = await session.execute(select(ChannelConfig).where(ChannelConfig.enabled.is_(True)))
return [self._to_runtime_config(config) for config in result.scalars().all()]
async def get_config(self, channel_type: str, account_id: str) -> dict:
"""读取指定账户的运行时配置字典(扁平化,含 config_json 内容)。"""
async with pg_manager.get_async_session_context() as session:
result = await session.execute(
select(ChannelConfig).where(
ChannelConfig.channel_type == channel_type,
ChannelConfig.account_id == account_id,
)
)
config = result.scalar_one_or_none()
if config is None:
raise ValueError(f"Channel config not found: {channel_type}/{account_id}")
return self._to_runtime_config(config)
async def get_admin_config(self, channel_type: str, account_id: str) -> dict:
"""读取指定账户的管理 API 配置字典(嵌套结构,保留 config_json 字段)。"""
async with pg_manager.get_async_session_context() as session:
result = await session.execute(
select(ChannelConfig).where(
ChannelConfig.channel_type == channel_type,
ChannelConfig.account_id == account_id,
)
)
config = result.scalar_one_or_none()
if config is None:
raise ValueError(f"Channel config not found: {channel_type}/{account_id}")
return self.to_config_dict(config)
async def list_all_accounts(
self,
channel_type: str | None = None,
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""返回所有渠道账户配置(管理 API 嵌套结构)。"""
async with pg_manager.get_async_session_context() as session:
query = select(ChannelConfig)
if channel_type:
query = query.where(ChannelConfig.channel_type == channel_type)
query = query.order_by(ChannelConfig.created_at.desc()).limit(limit).offset(offset)
result = await session.execute(query)
return [self.to_config_dict(config) for config in result.scalars().all()]
async def create_config(
self,
channel_type: str,
account_id: str,
config_json: dict,
name: str | None = None,
created_by: str | None = None,
) -> dict:
"""创建新的渠道账户配置。"""
self.validate_config_json(channel_type, config_json)
plugin = get_registry().get_plugin(channel_type)
if plugin is None:
raise ValueError(f"Unknown channel type: {channel_type}")
ok, errors = plugin.validate_config(config_json)
if not ok:
raise ValueError(f"Config validation failed for {channel_type}: {'; '.join(errors)}")
async with pg_manager.get_async_session_context() as session:
existing = await session.execute(
select(ChannelConfig).where(
ChannelConfig.channel_type == channel_type,
ChannelConfig.account_id == account_id,
)
)
if existing.scalar_one_or_none() is not None:
raise ChannelConfigAlreadyExistsError(f"Channel config already exists: {channel_type}/{account_id}")
config = ChannelConfig(
channel_type=channel_type,
account_id=account_id,
name=name,
config_json=config_json,
enabled=False,
created_by=created_by,
updated_by=created_by,
)
session.add(config)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ChannelConfigAlreadyExistsError(
f"Channel config already exists: {channel_type}/{account_id}"
) from exc
await session.refresh(config)
result = self.to_config_dict(config)
await self._notify_config_changed(channel_type, account_id, "created")
return result
async def update_config(
self,
channel_type: str,
account_id: str,
config_json: dict | None = None,
name: str | None = None,
enabled: bool | None = None,
updated_by: str | None = None,
) -> dict:
"""更新渠道账户配置。"""
async with pg_manager.get_async_session_context() as session:
result = await session.execute(
select(ChannelConfig).where(
ChannelConfig.channel_type == channel_type,
ChannelConfig.account_id == account_id,
)
)
config = result.scalar_one_or_none()
if config is None:
raise ValueError(f"Channel config not found: {channel_type}/{account_id}")
previous_enabled = bool(config.enabled)
if name is not None:
config.name = name
if enabled is not None:
config.enabled = enabled
if config_json is not None:
self.validate_config_json(channel_type, config_json)
plugin = get_registry().get_plugin(channel_type)
if plugin is None:
raise ValueError(f"Unknown channel type: {channel_type}")
ok, errors = plugin.validate_config(config_json)
if not ok:
raise ValueError(f"Config validation failed for {channel_type}: {'; '.join(errors)}")
config.config_json = config_json
if updated_by is not None:
config.updated_by = updated_by
await session.commit()
await session.refresh(config)
result = self.to_config_dict(config)
if enabled is not None and enabled != previous_enabled:
action = "enabled" if enabled else "disabled"
else:
action = "updated"
await self._notify_config_changed(channel_type, account_id, action)
return result
async def delete_config(
self,
channel_type: str,
account_id: str,
session: AsyncSession | None = None,
) -> bool:
"""删除渠道账户配置。
如果提供 session则在调用方事务中执行删除不提交也不发送通知
否则自行管理 session提交并发送通知。
"""
if session is None:
async with pg_manager.get_async_session_context() as session:
deleted = await self._delete_config_in_session(session, channel_type, account_id)
if deleted:
await session.commit()
if deleted:
await self._notify_config_changed(channel_type, account_id, "deleted")
return deleted
return await self._delete_config_in_session(session, channel_type, account_id)
async def _delete_config_in_session(
self,
session: AsyncSession,
channel_type: str,
account_id: str,
) -> bool:
"""在指定 session 中删除渠道账户配置(不提交)。"""
result = await session.execute(
select(ChannelConfig).where(
ChannelConfig.channel_type == channel_type,
ChannelConfig.account_id == account_id,
)
)
config = result.scalar_one_or_none()
if config is None:
return False
await session.delete(config)
return True
def to_config_dict(self, config: ChannelConfig) -> dict:
"""管理 API 返回的嵌套结构,保留 config_json 字段。"""
return {
"id": str(config.id) if config.id else None,
"channel_type": config.channel_type,
"account_id": config.account_id,
"name": config.name,
"enabled": bool(config.enabled),
"config_json": config.config_json or {},
"created_by": config.created_by,
"updated_by": config.updated_by,
"created_at": config.created_at.isoformat() if config.created_at else None,
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
}
def _to_runtime_config(self, config: ChannelConfig) -> dict:
"""Gateway 运行时使用的扁平化配置config_json 字段展开到顶层。
内置字段id/channel_type/account_id/name/enabled始终由模型值决定
避免 config_json 中的同名 key 覆盖运行时元数据。
"""
return {
**(config.config_json or {}),
"id": str(config.id) if config.id else None,
"channel_type": config.channel_type,
"account_id": config.account_id,
"name": config.name,
"enabled": bool(config.enabled),
}