from pydantic import BaseModel, Field from fastapi import APIRouter, Depends, HTTPException, Query, status from yuxi.channel.doctor import doctor_runner from yuxi.channel.protocols import DirectoryProtocol, DirectorySearchParams from yuxi.channel.plugins.registry import ChannelPluginRegistry from yuxi.services.channel_service import ( approve_channel_pairing, create_channel_binding, create_channel_config, create_thread_mapping, create_user_mapping, delete_channel_binding, delete_channel_config, delete_channel_pairing_record, delete_thread_mapping, delete_user_mapping, find_thread_mapping_by_channel_chat, find_thread_mapping_by_thread_id, find_user_mapping_by_channel, find_user_mappings_by_internal_user, generate_agent_pairing_code, get_channel_binding, get_channel_config, get_channel_pairing_record, get_gateway_health, get_gateway_healthz, get_hook_status, get_thread_mapping, get_user_mapping, install_hook, list_channel_bindings, list_channel_bindings_by_type, list_channel_configs, list_channel_pairing_records, list_user_mappings, reject_channel_pairing, reload_hooks, resolve_channel_thread, resolve_channel_user, uninstall_hook, update_channel_binding, update_channel_config, ) from yuxi.storage.postgres.models_business import User from server.utils.auth_middleware import get_admin_user channel = APIRouter(prefix="/system/channel", tags=["channel"]) gateway_router = APIRouter(prefix="/system/gateway", tags=["gateway"]) class RepairRequest(BaseModel): step_id: str = Field(..., min_length=1) account_id: str = "default" class ChannelConfigCreate(BaseModel): channel_type: str name: str config: dict | None = None class ChannelConfigUpdate(BaseModel): channel_type: str | None = None name: str | None = None config: dict | None = None enabled: bool | None = None class ChannelBindingCreate(BaseModel): channel_type: str channel_config_id: str | None = None account_id: str | None = None peer_kind: str | None = None peer_id: str | None = None agent_config_id: int priority: int = 0 dm_scope: str = "per-channel-peer" guild_id: str | None = None team_id: str | None = None roles: list[str] | None = None session_dm_scope: str | None = None class ChannelBindingUpdate(BaseModel): channel_type: str | None = None channel_config_id: str | None = None account_id: str | None = None peer_kind: str | None = None peer_id: str | None = None agent_config_id: int | None = None priority: int | None = None dm_scope: str | None = None guild_id: str | None = None team_id: str | None = None roles: list[str] | None = None session_dm_scope: str | None = None class ChannelUserMappingCreate(BaseModel): channel_id: str channel_user_id: str internal_user_id: str class ChannelThreadMappingCreate(BaseModel): channel_id: str channel_chat_id: str internal_user_id: str thread_id: str agent_id: str | None = None # ============================================================================= # === 渠道配置 CRUD === # ============================================================================= @channel.get("/channels") async def list_channels( channel_type: str | None = Query(None, description="按渠道类型过滤"), enabled: bool | None = Query(None, description="按启用状态过滤"), current_user: User = Depends(get_admin_user), ): return await list_channel_configs(channel_type=channel_type, enabled=enabled) @channel.get("/channels/{config_id}") async def get_channel(config_id: str, current_user: User = Depends(get_admin_user)): config = await get_channel_config(config_id) if not config: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="渠道配置不存在") return config @channel.post("/channels", status_code=status.HTTP_201_CREATED) async def create_channel( data: ChannelConfigCreate, current_user: User = Depends(get_admin_user), ): return await create_channel_config( { "channel_type": data.channel_type, "name": data.name, "config": data.config or {}, } ) @channel.put("/channels/{config_id}") async def update_channel( config_id: str, data: ChannelConfigUpdate, current_user: User = Depends(get_admin_user), ): update_data = {k: v for k, v in data.model_dump().items() if v is not None} config = await update_channel_config(config_id, update_data) if not config: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="渠道配置不存在") return config @channel.delete("/channels/{config_id}", status_code=status.HTTP_200_OK) async def delete_channel(config_id: str, current_user: User = Depends(get_admin_user)): deleted = await delete_channel_config(config_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="渠道配置不存在") return {"success": True, "message": "渠道配置已删除"} # ============================================================================= # === 渠道绑定 CRUD === # ============================================================================= @channel.get("/bindings") async def list_bindings( agent_config_id: int | None = Query(None, description="按 AgentConfig ID 过滤"), channel_type: str | None = Query(None, description="按渠道类型过滤"), current_user: User = Depends(get_admin_user), ): if channel_type: return await list_channel_bindings_by_type(channel_type) return await list_channel_bindings(agent_config_id=agent_config_id) @channel.get("/bindings/{binding_id}") async def get_binding(binding_id: str, current_user: User = Depends(get_admin_user)): binding = await get_channel_binding(binding_id) if not binding: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="渠道绑定不存在") return binding @channel.post("/bindings", status_code=status.HTTP_201_CREATED) async def create_binding( data: ChannelBindingCreate, current_user: User = Depends(get_admin_user), ): return await create_channel_binding(data.model_dump()) @channel.put("/bindings/{binding_id}") async def update_binding( binding_id: str, data: ChannelBindingUpdate, current_user: User = Depends(get_admin_user), ): update_data = {k: v for k, v in data.model_dump().items() if v is not None} binding = await update_channel_binding(binding_id, update_data) if not binding: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="渠道绑定不存在") return binding @channel.delete("/bindings/{binding_id}", status_code=status.HTTP_200_OK) async def delete_binding(binding_id: str, current_user: User = Depends(get_admin_user)): deleted = await delete_channel_binding(binding_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="渠道绑定不存在") return {"success": True, "message": "渠道绑定已删除"} # ============================================================================= # === 配对管理 === # ============================================================================= @channel.get("/pairing") async def list_pairing( status_filter: str | None = Query(None, alias="status"), current_user: User = Depends(get_admin_user), ): return await list_channel_pairing_records(status=status_filter) @channel.get("/pairing/agent/{agent_config_id}/code") async def get_agent_pairing_code( agent_config_id: int, current_user: User = Depends(get_admin_user), ): try: return await generate_agent_pairing_code(agent_config_id) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @channel.post("/pairing/{record_id}/approve") async def approve_pairing( record_id: str, current_user: User = Depends(get_admin_user), ): record = await approve_channel_pairing(record_id) if not record: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="配对记录不存在") return record @channel.post("/pairing/{record_id}/reject") async def reject_pairing( record_id: str, current_user: User = Depends(get_admin_user), ): record = await reject_channel_pairing(record_id) if not record: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="配对记录不存在") return record @channel.get("/pairing/{record_id}") async def get_pairing( record_id: str, current_user: User = Depends(get_admin_user), ): record = await get_channel_pairing_record(record_id) if not record: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="配对记录不存在") return record @channel.delete("/pairing/{record_id}", status_code=status.HTTP_200_OK) async def delete_pairing( record_id: str, current_user: User = Depends(get_admin_user), ): deleted = await delete_channel_pairing_record(record_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="配对记录不存在") return {"success": True, "message": "配对记录已删除"} # ============================================================================= # === Gateway 健康检查 === # ============================================================================= @gateway_router.get("/health") async def gateway_health(): return await get_gateway_health() @gateway_router.get("/healthz") async def gateway_healthz(): return await get_gateway_healthz() @gateway_router.get("/health/ready") async def gateway_readiness(): from yuxi.channel.runtime.manager import gateway trace = gateway.get_startup_trace() if trace is None: return { "ready": False, "state": "not_ready", "reason": "Gateway not started", } success = bool(trace.get("completed_at")) and not trace.get("failed_at") ready_state = trace.get("ready_state", "unknown") return { "ready": success and ready_state in ("ready", "degraded"), "state": ready_state, "trace": trace, } @gateway_router.get("/health/boot") async def gateway_boot_status(): from yuxi.channel.runtime.manager import gateway return gateway.get_boot_status() @gateway_router.get("/health/channels") async def gateway_channels_health(): from yuxi.channel.monitoring.readiness import readiness_checker result = await readiness_checker.check() return { "status": "healthy" if result.ready else "degraded", "timestamp": result.checked_at, "channels": [ { "key": c.key, "healthy": c.healthy, "reason": c.reason, "detail": c.detail, } for c in result.channels ], "failing": result.failing, "uptime_ms": result.uptime_ms, } @gateway_router.get("/health/channels/metrics") async def gateway_channels_health_metrics(): from yuxi.channel.monitoring.health_monitor import channel_health_monitor return channel_health_monitor.metrics.to_dict() @gateway_router.get("/hooks") async def gateway_hook_status(): return await get_hook_status() class InstallHookRequest(BaseModel): hook_id: str hook_md_content: str target: str = "workspace" @gateway_router.post("/hooks/install", status_code=status.HTTP_201_CREATED) async def gateway_install_hook(req: InstallHookRequest, _user=Depends(get_admin_user)): return await install_hook(req.hook_id, req.hook_md_content, req.target) @gateway_router.delete("/hooks/{source}/{hook_id}") async def gateway_uninstall_hook(source: str, hook_id: str, _user=Depends(get_admin_user)): result = await uninstall_hook(source, hook_id) if not result.get("success"): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=result.get("error")) return result @gateway_router.post("/hooks/reload") async def gateway_reload_hooks(_user=Depends(get_admin_user)): return await reload_hooks() # ============================================================================= # === 渠道诊断 === # ============================================================================= async def _resolve_config(channel_type: str, account_id: str) -> dict: plugin = ChannelPluginRegistry.get(channel_type) if plugin is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"渠道 {channel_type} 未注册") return await plugin.resolve_account(account_id) @channel.get("/{channel_type}/diagnose") async def diagnose_channel( channel_type: str, account_id: str = Query("default"), current_user: User = Depends(get_admin_user), ): config = await _resolve_config(channel_type, account_id) result = await doctor_runner.diagnose(channel_type, account_id, config) return result.to_dict() @channel.post("/{channel_type}/diagnose/repair") async def repair_channel( channel_type: str, body: RepairRequest, current_user: User = Depends(get_admin_user), ): config = await _resolve_config(channel_type, body.account_id) result = await doctor_runner.repair(channel_type, body.step_id, config) return { "step_id": result.step_id, "success": result.success, "message": result.message, } # ============================================================================= # === Directory 目录查询 === # ============================================================================= async def _resolve_plugin_direct(config_id: str): config = await get_channel_config(config_id) if not config: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="渠道配置不存在") channel_type = config["channel_type"] plugin = ChannelPluginRegistry.get(channel_type) if not plugin or not isinstance(plugin, DirectoryProtocol): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"渠道 {channel_type} 不支持目录查询", ) return plugin, config def _plugin_config(config: dict) -> dict: cfg = config.get("config") return cfg if isinstance(cfg, dict) else {} @channel.get("/{config_id}/directory/peers") async def list_directory_peers( config_id: str, query: str = Query(default=""), limit: int = Query(default=20, ge=1, le=200), offset: int = Query(default=0, ge=0), current_user: User = Depends(get_admin_user), ): plugin, config = await _resolve_plugin_direct(config_id) plugin_cfg = _plugin_config(config) if query: params = DirectorySearchParams(query=query, kind="user", limit=limit, offset=offset) result = await plugin.search_peers(plugin_cfg, params) return {"items": [item.__dict__ for item in result.items], "total": result.total, "has_more": result.has_more} peers = await plugin.list_peers(plugin_cfg) return {"items": [p.__dict__ for p in peers], "total": len(peers)} @channel.get("/{config_id}/directory/groups") async def list_directory_groups( config_id: str, query: str = Query(default=""), limit: int = Query(default=20, ge=1, le=200), offset: int = Query(default=0, ge=0), current_user: User = Depends(get_admin_user), ): plugin, config = await _resolve_plugin_direct(config_id) plugin_cfg = _plugin_config(config) if query: params = DirectorySearchParams(query=query, kind="group", limit=limit, offset=offset) result = await plugin.search_groups(plugin_cfg, params) return {"items": [item.__dict__ for item in result.items], "total": result.total, "has_more": result.has_more} groups = await plugin.list_groups(plugin_cfg) return {"items": [g.__dict__ for g in groups], "total": len(groups)} @channel.get("/{config_id}/directory/peers/{peer_id}") async def get_directory_peer( config_id: str, peer_id: str, current_user: User = Depends(get_admin_user), ): plugin, config = await _resolve_plugin_direct(config_id) peer = await plugin.get_peer(_plugin_config(config), peer_id) if peer is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") return peer.__dict__ @channel.get("/{config_id}/directory/groups/{group_id}") async def get_directory_group( config_id: str, group_id: str, current_user: User = Depends(get_admin_user), ): plugin, config = await _resolve_plugin_direct(config_id) group = await plugin.get_group(_plugin_config(config), group_id) if group is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="群组不存在") return group.__dict__ # ============================================================================= # === 渠道用户映射 CRUD === # ============================================================================= @channel.get("/user-mappings/{mapping_id}") async def get_user_mapping_endpoint(mapping_id: int, current_user: User = Depends(get_admin_user)): mapping = await get_user_mapping(mapping_id) if not mapping: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户映射不存在") return mapping @channel.get("/user-mappings") async def find_user_mapping( channel_id: str | None = Query(default=None), channel_user_id: str | None = Query(default=None), internal_user_id: str | None = Query(default=None), current_user: User = Depends(get_admin_user), ): if channel_id and channel_user_id: mapping = await find_user_mapping_by_channel(channel_id, channel_user_id) return {"items": [mapping] if mapping else [], "total": 1 if mapping else 0} if internal_user_id: mappings = await find_user_mappings_by_internal_user(internal_user_id) return {"items": mappings, "total": len(mappings)} mappings = await list_user_mappings() return {"items": mappings, "total": len(mappings)} @channel.post("/user-mappings", status_code=status.HTTP_201_CREATED) async def create_user_mapping_endpoint( data: ChannelUserMappingCreate, current_user: User = Depends(get_admin_user), ): return await create_user_mapping(data.model_dump()) @channel.post("/user-mappings/resolve") async def resolve_user_mapping( channel_id: str = Query(...), channel_user_id: str = Query(...), current_user: User = Depends(get_admin_user), ): return await resolve_channel_user(channel_id, channel_user_id) @channel.delete("/user-mappings/{mapping_id}", status_code=status.HTTP_200_OK) async def delete_user_mapping_endpoint(mapping_id: int, current_user: User = Depends(get_admin_user)): deleted = await delete_user_mapping(mapping_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户映射不存在") return {"success": True, "message": "用户映射已删除"} # ============================================================================= # === 渠道会话线程映射 CRUD === # ============================================================================= @channel.get("/thread-mappings/{mapping_id}") async def get_thread_mapping_endpoint(mapping_id: int, current_user: User = Depends(get_admin_user)): mapping = await get_thread_mapping(mapping_id) if not mapping: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="线程映射不存在") return mapping @channel.get("/thread-mappings") async def find_thread_mapping( channel_id: str | None = Query(default=None), channel_chat_id: str | None = Query(default=None), internal_user_id: str | None = Query(default=None), thread_id: str | None = Query(default=None), current_user: User = Depends(get_admin_user), ): if channel_id and channel_chat_id and internal_user_id: mapping = await find_thread_mapping_by_channel_chat( channel_id, channel_chat_id, internal_user_id, ) return {"items": [mapping] if mapping else [], "total": 1 if mapping else 0} if thread_id: mapping = await find_thread_mapping_by_thread_id(thread_id) return {"items": [mapping] if mapping else [], "total": 1 if mapping else 0} raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="请提供 channel_id+channel_chat_id+internal_user_id 或 thread_id", ) @channel.post("/thread-mappings", status_code=status.HTTP_201_CREATED) async def create_thread_mapping_endpoint( data: ChannelThreadMappingCreate, current_user: User = Depends(get_admin_user), ): return await create_thread_mapping(data.model_dump()) @channel.post("/thread-mappings/resolve") async def resolve_thread_mapping( channel_id: str = Query(...), channel_chat_id: str = Query(...), internal_user_id: str = Query(...), agent_id: str | None = Query(default=None), current_user: User = Depends(get_admin_user), ): return await resolve_channel_thread(channel_id, channel_chat_id, internal_user_id, agent_id) @channel.delete("/thread-mappings/{mapping_id}", status_code=status.HTTP_200_OK) async def delete_thread_mapping_endpoint(mapping_id: int, current_user: User = Depends(get_admin_user)): deleted = await delete_thread_mapping(mapping_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="线程映射不存在") return {"success": True, "message": "线程映射已删除"} @channel.get("/ui-schemas") async def list_channel_ui_schemas(current_user: User = Depends(get_admin_user)): from yuxi.channel.ui.schema import list_all_ui_metadata schemas = [m.to_dict() for m in list_all_ui_metadata()] return {"schemas": schemas, "total": len(schemas)} @channel.get("/ui-schemas/{channel_type}") async def get_channel_ui_schema(channel_type: str, current_user: User = Depends(get_admin_user)): from yuxi.channel.ui.schema import get_channel_ui_metadata metadata = get_channel_ui_metadata(channel_type) if metadata is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"渠道类型 '{channel_type}' 的 UI Schema 不存在", ) return metadata.to_dict()