361 lines
15 KiB
Python
361 lines
15 KiB
Python
"""渠道配置管理器"""
|
||
|
||
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),
|
||
}
|