feat(channel): 添加渠道网关服务端路由和端点
新增全渠道网关的 API 路由层和网关端点。 主要变更: - channel_router.py: 渠道管理 API 路由(CRUD、配置、绑定等) - gateway_sse_router.py: SSE 网关端点 - gateway_ws_router.py: WebSocket 网关端点 - webhook_router.py: Webhook 回调路由 - auth_router.py: 认证路由更新 - routers/__init__.py: 路由注册更新 - main.py: 主入口更新 - lifespan.py: 应用生命周期更新,渠道扩展加载与卸载
This commit is contained in:
parent
fb754d82f1
commit
66916f9b36
@ -21,6 +21,9 @@ from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from server.routers import router
|
||||
from server.routers.gateway_ws_router import router as gateway_ws_router
|
||||
from server.routers.webhook_router import router as webhook_router
|
||||
from yuxi.channel.gateway.sse import router as gateway_sse_router
|
||||
from server.utils.lifespan import lifespan
|
||||
from server.utils.auth_middleware import is_public_path
|
||||
from server.utils.common_utils import setup_logging
|
||||
@ -40,6 +43,9 @@ _attempt_lock = asyncio.Lock()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
# 所有业务接口统一挂载到 /api,具体分组在 server.routers 中集中注册。
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(gateway_sse_router)
|
||||
app.include_router(gateway_ws_router)
|
||||
app.include_router(webhook_router)
|
||||
|
||||
# CORS 设置
|
||||
app.add_middleware(
|
||||
|
||||
@ -2,10 +2,14 @@ import os
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from server.routers.auth_apikey_router import apikey_router
|
||||
from server.routers.auth_dept_router import department
|
||||
from server.routers.auth_router import auth
|
||||
from server.routers.channel_router import channel, gateway_router
|
||||
from server.routers.chat_router import chat
|
||||
from server.routers.dashboard_router import dashboard
|
||||
from server.routers.auth_dept_router import department
|
||||
from server.routers.filesystem_router import filesystem_router
|
||||
from server.routers.gateway_sse_router import sse_polling_router
|
||||
from server.routers.mcp_router import mcp
|
||||
from server.routers.model_provider_router import model_providers
|
||||
from server.routers.skill_router import skills
|
||||
@ -13,8 +17,6 @@ from server.routers.subagent_router import subagents_router
|
||||
from server.routers.system_router import system
|
||||
from server.routers.system_task_router import tasks
|
||||
from server.routers.tool_router import tools
|
||||
from server.routers.auth_apikey_router import apikey_router
|
||||
from server.routers.filesystem_router import filesystem_router
|
||||
from server.routers.workspace_router import workspace
|
||||
|
||||
_LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1")
|
||||
@ -23,6 +25,9 @@ router = APIRouter()
|
||||
|
||||
# 基础系统接口:健康检查、配置、认证与聊天主链路。
|
||||
router.include_router(system) # /api/system/* 系统状态与全局配置
|
||||
router.include_router(channel) # /api/system/channel/* 渠道网关管理
|
||||
router.include_router(gateway_router) # /api/system/gateway/* 渠道网关健康检查
|
||||
router.include_router(sse_polling_router) # /api/poll SSE 流轮询降级
|
||||
router.include_router(auth) # /api/auth/* 登录与用户信息
|
||||
router.include_router(chat) # /api/chat/* 对话、消息流、运行态
|
||||
|
||||
@ -41,9 +46,9 @@ router.include_router(workspace) # /api/workspace/* 用户个人工作区
|
||||
|
||||
if not _LITE_MODE:
|
||||
from server.routers.graph_router import graph
|
||||
from server.routers.knowledge_router import knowledge
|
||||
from server.routers.knowledge_eval_router import evaluation
|
||||
from server.routers.knowledge_mindmap_router import mindmap
|
||||
from server.routers.knowledge_router import knowledge
|
||||
|
||||
# 知识库与图谱能力依赖较重,LITE 模式下跳过这组接口。
|
||||
router.include_router(knowledge) # /api/knowledge/* 知识库管理与检索
|
||||
|
||||
@ -50,6 +50,7 @@ class Token(BaseModel):
|
||||
role: str
|
||||
department_id: int | None = None
|
||||
department_name: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
@ -58,6 +59,7 @@ class UserCreate(BaseModel):
|
||||
role: str = "user"
|
||||
phone_number: str | None = None
|
||||
department_id: int | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
@ -67,6 +69,7 @@ class UserUpdate(BaseModel):
|
||||
phone_number: str | None = None
|
||||
avatar: str | None = None
|
||||
department_id: int | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
@ -83,6 +86,7 @@ class UserResponse(BaseModel):
|
||||
role: str
|
||||
department_id: int | None = None
|
||||
department_name: str | None = None # 部门名称
|
||||
source: str | None = None
|
||||
created_at: str
|
||||
last_login: str | None = None
|
||||
|
||||
@ -124,6 +128,7 @@ class OIDCLoginResponse(BaseModel):
|
||||
role: str
|
||||
department_id: int | None = None
|
||||
department_name: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -236,6 +241,7 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
|
||||
"role": user.role,
|
||||
"department_id": user.department_id,
|
||||
"department_name": department_name,
|
||||
"source": user.source,
|
||||
}
|
||||
|
||||
|
||||
@ -319,6 +325,7 @@ async def initialize_admin(admin_data: InitializeAdmin, db: AsyncSession = Depen
|
||||
"phone_number": new_admin.phone_number,
|
||||
"avatar": new_admin.avatar,
|
||||
"role": new_admin.role,
|
||||
"source": new_admin.source,
|
||||
}
|
||||
|
||||
|
||||
@ -492,6 +499,7 @@ async def create_user(
|
||||
"password_hash": hashed_password,
|
||||
"role": user_data.role,
|
||||
"department_id": department_id,
|
||||
"source": user_data.source or "local",
|
||||
}
|
||||
)
|
||||
|
||||
@ -623,6 +631,10 @@ async def update_user(
|
||||
user.avatar = user_data.avatar
|
||||
update_details.append(f"头像: {user_data.avatar or '已清空'}")
|
||||
|
||||
if user_data.source is not None:
|
||||
user.source = user_data.source
|
||||
update_details.append(f"来源: {user_data.source}")
|
||||
|
||||
# 部门修改权限控制(只有超级管理员可以修改用户部门)
|
||||
if user_data.department_id is not None and user_data.department_id != user.department_id:
|
||||
if current_user.role != "superadmin":
|
||||
@ -857,6 +869,7 @@ async def impersonate_user(
|
||||
"role": target_user.role,
|
||||
"department_id": target_user.department_id,
|
||||
"department_name": department_name,
|
||||
"source": target_user.source,
|
||||
}
|
||||
|
||||
|
||||
|
||||
660
backend/server/routers/channel_router.py
Normal file
660
backend/server/routers/channel_router.py
Normal file
@ -0,0 +1,660 @@
|
||||
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()
|
||||
22
backend/server/routers/gateway_sse_router.py
Normal file
22
backend/server/routers/gateway_sse_router.py
Normal file
@ -0,0 +1,22 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user
|
||||
from yuxi.channel.gateway.polling import polling_fallback
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sse_polling_router = APIRouter(prefix="/poll", tags=["sse-polling"])
|
||||
|
||||
|
||||
@sse_polling_router.get("")
|
||||
async def sse_poll(
|
||||
session_id: str = Query(..., description="轮询会话 ID"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
events = await polling_fallback.poll(session_id)
|
||||
if not events:
|
||||
return {"status": "empty", "data": None}
|
||||
return {"status": "ok", "data": events}
|
||||
12
backend/server/routers/gateway_ws_router.py
Normal file
12
backend/server/routers/gateway_ws_router.py
Normal file
@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter, Query, WebSocket
|
||||
from yuxi.channel.gateway.server import gateway_ws_server
|
||||
|
||||
router = APIRouter(tags=["Gateway WebSocket"])
|
||||
|
||||
|
||||
@router.websocket("/ws/gateway")
|
||||
async def gateway_ws_endpoint(
|
||||
ws: WebSocket,
|
||||
token: str | None = Query(None),
|
||||
):
|
||||
await gateway_ws_server.handle_connection(ws, token)
|
||||
56
backend/server/routers/webhook_router.py
Normal file
56
backend/server/routers/webhook_router.py
Normal file
@ -0,0 +1,56 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
|
||||
from yuxi.channel.gateway.routes import webhook_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhook", tags=["webhook"])
|
||||
|
||||
|
||||
def _extract_signature(headers: dict[str, str]) -> str | None:
|
||||
sig = headers.get("x-hub-signature-256") or headers.get("x-signature")
|
||||
if sig and sig.startswith("sha256="):
|
||||
sig = sig[7:]
|
||||
return sig
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/{channel_type}",
|
||||
methods=["POST", "PUT", "PATCH"],
|
||||
summary="接收渠道 Webhook 回调",
|
||||
)
|
||||
async def webhook_receive(
|
||||
channel_type: str,
|
||||
request: Request,
|
||||
):
|
||||
body = await request.body()
|
||||
method = request.method
|
||||
content_type = request.headers.get("content-type")
|
||||
headers = {k.lower(): v for k, v in request.headers.items()}
|
||||
|
||||
guard_result, handler_result = await webhook_registry.dispatch_guarded(
|
||||
channel_type=channel_type,
|
||||
method=method,
|
||||
content_type=content_type,
|
||||
body=body,
|
||||
signature=_extract_signature(headers),
|
||||
extra_headers=headers,
|
||||
)
|
||||
|
||||
if guard_result is not None:
|
||||
logger.warning(
|
||||
"Webhook guard blocked: channel=%s method=%s status=%d error=%s",
|
||||
channel_type,
|
||||
method,
|
||||
guard_result.http_status,
|
||||
guard_result.error_code,
|
||||
)
|
||||
return Response(
|
||||
content=guard_result.error_message or "",
|
||||
status_code=guard_result.http_status,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
return handler_result or {"status": "ok"}
|
||||
@ -80,6 +80,13 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize sandbox provider during startup: {e}")
|
||||
|
||||
try:
|
||||
from yuxi.channel.gateway.rpc_handlers import register_all_handlers
|
||||
register_all_handlers()
|
||||
logger.info("Gateway RPC handlers registered (%d methods)", 42)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register gateway RPC handlers: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 2. 核心修复:在这里执行一次 setup(),建完表就拉倒
|
||||
# =========================================================
|
||||
@ -87,6 +94,19 @@ async def lifespan(app: FastAPI):
|
||||
await checkpointer.setup()
|
||||
print("LangGraph Checkpoint tables verified/created!")
|
||||
|
||||
try:
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
|
||||
await gateway.boot()
|
||||
trace = gateway.get_startup_trace()
|
||||
if not trace.get("success"):
|
||||
logger.error("ChannelGateway boot failed: %s", trace.get("error"))
|
||||
else:
|
||||
logger.info("ChannelGateway ready, %d channels, %.2fs",
|
||||
len(gateway.list_channels()), trace.get("duration_seconds", 0))
|
||||
except Exception as e:
|
||||
logger.error("ChannelGateway boot error: %s", e)
|
||||
|
||||
await tasker.start()
|
||||
logger.info(f"""
|
||||
|
||||
@ -102,6 +122,13 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Yuxi backend startup complete")
|
||||
yield
|
||||
await tasker.shutdown()
|
||||
|
||||
try:
|
||||
from yuxi.channel.gateway.server import gateway_ws_server
|
||||
await gateway_ws_server.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(f"Gateway WS shutdown error: {e}")
|
||||
|
||||
shutdown_sandbox_provider()
|
||||
await close_queue_clients()
|
||||
await pg_manager.close()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user