From 56022a9199b8338029d2635036e29e99a36fb38b Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Sat, 11 Jul 2026 22:06:21 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=89=B9=E9=87=8F=E6=95=B4=E7=90=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=8F=98=E6=9B=B4=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BB=86=E8=8A=82=E4=B8=8E=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 scheduler store 末尾多余逗号 - 将智能体名称从“语析”改为“Kris” - 新增获取运行记录UID的权限校验接口 - 重构操作日志写入逻辑,使用独立会话避免事务污染 - 注册外部系统调度处理器 - 优化全局错误处理器,统一响应格式与序列化处理 - 新增调度任务运行日志的handler_name字段与索引 - 重构任务执行函数,新增payload覆盖与操作人审计参数 - 重写外部系统store,新增侧边栏折叠状态与持久化 - 优化会话、消息等模型的索引、约束与字段类型 - 新增多项配置项与环境变量支持 - 重构渠道相关模型,新增索引、约束与字段优化 - 新增渠道插件模型与内容审核模型的完善 --- .../yuxi/agents/buildin/chatbot/prompt.py | 2 +- backend/package/yuxi/config/app.py | 94 +- .../yuxi/services/agent_run_service.py | 15 + .../yuxi/services/operation_log_service.py | 22 +- backend/package/yuxi/services/run_worker.py | 12 +- .../package/yuxi/services/scheduler_tick.py | 24 +- .../yuxi/storage/postgres/models_business.py | 86 +- .../yuxi/storage/postgres/models_channels.py | 291 ++++- .../yuxi/storage/postgres/models_external.py | 788 ++++++++++-- .../yuxi/storage/postgres/models_scheduler.py | 24 + backend/server/utils/error_handlers.py | 15 +- web/src/router/index.js | 1094 +++++++++-------- web/src/stores/externalSystems.js | 170 +-- web/src/stores/scheduler.js | 2 +- 14 files changed, 1874 insertions(+), 765 deletions(-) diff --git a/backend/package/yuxi/agents/buildin/chatbot/prompt.py b/backend/package/yuxi/agents/buildin/chatbot/prompt.py index e60dc932..7d5298fd 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/prompt.py +++ b/backend/package/yuxi/agents/buildin/chatbot/prompt.py @@ -7,7 +7,7 @@ from yuxi.utils.paths import ( ) PROMPT = f""" -你是一个交互式智能体“语析“。 +你是一个交互式智能体“Kris“。 专门用来回答用户的问题。请根据用户提供的信息,尽可能详细地回答问题。 如果你不确定答案,可以说你不知道,但请尽量提供相关的信息或建议。请保持礼貌和专业。 diff --git a/backend/package/yuxi/config/app.py b/backend/package/yuxi/config/app.py index c15ae935..e15990ad 100644 --- a/backend/package/yuxi/config/app.py +++ b/backend/package/yuxi/config/app.py @@ -4,7 +4,7 @@ from __future__ import annotations import os from pathlib import Path -from typing import Any +from typing import Any, Literal import tomli import tomli_w @@ -65,6 +65,7 @@ class Config(BaseModel): scheduler_stagger_max_seconds: int = Field(default=300, description="整点抖动上限(秒)") scheduler_run_log_retention_days: int = Field(default=90, description="run_logs 保留天数") scheduler_idempotency_retention_hours: int = Field(default=24, description="幂等记录保留小时数") + scheduler_task_recycle_retention_days: int = Field(default=7, description="回收站任务保留天数,超期自动硬删除") scheduler_min_cron_interval_minutes: int = Field(default=10, description="cron 最小间隔(分钟)") scheduler_backoff_schedule: list[int] = Field( default_factory=lambda: [10, 30, 120, 600, 3600], @@ -78,7 +79,9 @@ class Config(BaseModel): ) web_search_searxng_base_url: str = Field(default="http://searxng:8080", description="SearXNG 服务地址") web_search_searxng_timeout: int = Field(default=10, description="SearXNG 调用超时(秒)") - web_search_searxng_engines: list[str] = Field(default_factory=list, description="SearXNG 指定引擎列表(空=使用默认)") + web_search_searxng_engines: list[str] = Field( + default_factory=list, description="SearXNG 指定引擎列表(空=使用默认)" + ) web_search_searxng_language: str = Field(default="auto", description="SearXNG 搜索语言") web_search_searxng_safesearch: int = Field(default=0, description="SearXNG 安全搜索级别(0/1/2)") web_search_tavily_api_key: str = Field(default="", description="Tavily API Key(空=读 TAVILY_API_KEY 环境变量)") @@ -93,10 +96,36 @@ class Config(BaseModel): web_crawl_default_max_depth: int = Field(default=2, description="Web 抓取默认递归深度") web_crawl_default_max_pages: int = Field(default=20, description="Web 抓取默认最大页数") web_crawl_default_concurrency: int = Field(default=5, description="Web 抓取默认并发数") - web_crawl_domain_whitelist: list[str] = Field(default_factory=list, description="Web 抓取域名白名单(空=允许所有公网域名)") + web_crawl_domain_whitelist: list[str] = Field( + default_factory=list, description="Web 抓取域名白名单(空=允许所有公网域名)" + ) web_crawl_respect_robots: bool = Field(default=True, description="Web 抓取是否尊重 robots.txt") web_crawl_rate_limit_per_minute: int = Field(default=10, description="单 agent Web 抓取每分钟调用上限") + # === 渠道网关全局配置(扁平字段,见数据库设计方案 §11.1) === + channel_outbox_ttl_seconds: int = Field(default=86400, description="Outbox 条目过期时间(秒,默认 24h)") + channel_outbox_max_retry: int = Field(default=5, description="Outbox 最大重试次数") + channel_outbox_retry_backoff_schedule: list[int] = Field( + default_factory=lambda: [2, 4, 8, 16, 32], + description="Outbox 指数退避序列(秒)", + ) + channel_audit_log_retention_days: int = Field(default=90, description="审计日志保留天数") + ext_system_audit_log_retention_days: int = Field(default=90, description="外部系统审计日志保留天数") + + # === Webhook 回调限流配置(扁平字段) === + webhook_callback_rate_limit_max_qps: int = Field( + default=10, ge=1, description="单个 Webhook 订阅回调每秒最大请求数" + ) + webhook_callback_rate_limit_burst_capacity: int = Field( + default=20, ge=1, description="单个 Webhook 订阅回调令牌桶突发容量" + ) + + # === 实时指标后端配置(进程级基础设施参数,H-6) === + realtime_metrics_backend: Literal["inmemory", "redis"] = Field( + default="inmemory", + description="实时指标后端(inmemory=纯内存单实例,redis=多实例共享)", + ) + _config_file: Path | None = PrivateAttr(default=None) _user_modified_fields: set[str] = PrivateAttr(default_factory=set) @@ -164,9 +193,7 @@ class Config(BaseModel): self.ssrf_allowed_domains = [d.strip() for d in ssrf_env.split(",") if d.strip()] # === scheduler 环境变量覆盖(对齐既有三段式写法,见设计方案 §13) === - self.scheduler_enabled = ( - os.getenv("SCHEDULER_ENABLED", "true").lower() in ("true", "1", "yes") - ) + self.scheduler_enabled = os.getenv("SCHEDULER_ENABLED", "true").lower() in ("true", "1", "yes") self.scheduler_tick_interval_seconds = int( os.getenv("SCHEDULER_TICK_INTERVAL_SECONDS") or self.scheduler_tick_interval_seconds or 60 ) @@ -180,9 +207,7 @@ class Config(BaseModel): os.getenv("SCHEDULER_MAX_PAYLOAD_SIZE_KB") or self.scheduler_max_payload_size_kb or 64 ) self.scheduler_max_active_tasks_per_owner = int( - os.getenv("SCHEDULER_MAX_ACTIVE_TASKS_PER_OWNER") - or self.scheduler_max_active_tasks_per_owner - or 100 + os.getenv("SCHEDULER_MAX_ACTIVE_TASKS_PER_OWNER") or self.scheduler_max_active_tasks_per_owner or 100 ) self.scheduler_stagger_max_seconds = int( os.getenv("SCHEDULER_STAGGER_MAX_SECONDS") or self.scheduler_stagger_max_seconds or 300 @@ -191,57 +216,50 @@ class Config(BaseModel): os.getenv("SCHEDULER_RUN_LOG_RETENTION_DAYS") or self.scheduler_run_log_retention_days or 90 ) self.scheduler_idempotency_retention_hours = int( - os.getenv("SCHEDULER_IDEMPOTENCY_RETENTION_HOURS") - or self.scheduler_idempotency_retention_hours - or 24 + os.getenv("SCHEDULER_IDEMPOTENCY_RETENTION_HOURS") or self.scheduler_idempotency_retention_hours or 24 + ) + self.scheduler_task_recycle_retention_days = int( + os.getenv("SCHEDULER_TASK_RECYCLE_RETENTION_DAYS") or self.scheduler_task_recycle_retention_days or 7 ) self.scheduler_min_cron_interval_minutes = int( - os.getenv("SCHEDULER_MIN_CRON_INTERVAL_MINUTES") - or self.scheduler_min_cron_interval_minutes - or 10 + os.getenv("SCHEDULER_MIN_CRON_INTERVAL_MINUTES") or self.scheduler_min_cron_interval_minutes or 10 ) # === Web 搜索环境变量覆盖 === - self.web_search_searxng_base_url = ( - os.getenv("WEB_SEARCH_SEARXNG_BASE_URL") or self.web_search_searxng_base_url - ) + self.web_search_searxng_base_url = os.getenv("WEB_SEARCH_SEARXNG_BASE_URL") or self.web_search_searxng_base_url self.web_search_searxng_timeout = int( os.getenv("WEB_SEARCH_SEARXNG_TIMEOUT") or self.web_search_searxng_timeout ) - self.web_search_searxng_language = ( - os.getenv("WEB_SEARCH_SEARXNG_LANGUAGE") or self.web_search_searxng_language - ) + self.web_search_searxng_language = os.getenv("WEB_SEARCH_SEARXNG_LANGUAGE") or self.web_search_searxng_language self.web_search_searxng_safesearch = int( os.getenv("WEB_SEARCH_SEARXNG_SAFESEARCH") or self.web_search_searxng_safesearch ) - self.web_search_tavily_api_key = ( - os.getenv("TAVILY_API_KEY") or self.web_search_tavily_api_key - ) - self.web_search_tavily_timeout = int( - os.getenv("WEB_SEARCH_TAVILY_TIMEOUT") or self.web_search_tavily_timeout - ) + self.web_search_tavily_api_key = os.getenv("TAVILY_API_KEY") or self.web_search_tavily_api_key + self.web_search_tavily_timeout = int(os.getenv("WEB_SEARCH_TAVILY_TIMEOUT") or self.web_search_tavily_timeout) # === Web 抓取环境变量覆盖 === - self.crawl4ai_base_url = ( - os.getenv("CRAWL4AI_BASE_URL") or self.crawl4ai_base_url + self.crawl4ai_base_url = os.getenv("CRAWL4AI_BASE_URL") or self.crawl4ai_base_url + self.crawl4ai_timeout = int(os.getenv("CRAWL4AI_TIMEOUT") or self.crawl4ai_timeout) + self.crawl4ai_api_token = os.getenv("CRAWL4AI_API_TOKEN") or self.crawl4ai_api_token + + # === Webhook 回调限流环境变量覆盖 === + self.webhook_callback_rate_limit_max_qps = int( + os.getenv("WEBHOOK_CALLBACK_RATE_LIMIT_MAX_QPS") or self.webhook_callback_rate_limit_max_qps ) - self.crawl4ai_timeout = int( - os.getenv("CRAWL4AI_TIMEOUT") or self.crawl4ai_timeout - ) - self.crawl4ai_api_token = ( - os.getenv("CRAWL4AI_API_TOKEN") or self.crawl4ai_api_token + self.webhook_callback_rate_limit_burst_capacity = int( + os.getenv("WEBHOOK_CALLBACK_RATE_LIMIT_BURST_CAPACITY") or self.webhook_callback_rate_limit_burst_capacity ) + # === 实时指标后端环境变量覆盖(H-6) === + self.realtime_metrics_backend = os.getenv("REALTIME_METRICS_BACKEND", self.realtime_metrics_backend) + @model_validator(mode="after") def _validate_web_search_crawl_config(self): """校验 web_search 与 web_crawl 配置合法性。""" valid_providers = {"searxng", "tavily"} for provider in self.web_search_provider_chain: if provider not in valid_providers: - raise ValueError( - f"web_search_provider_chain 含未知 Provider 名: {provider}," - f"合法值: {valid_providers}" - ) + raise ValueError(f"web_search_provider_chain 含未知 Provider 名: {provider},合法值: {valid_providers}") if not 1 <= self.web_search_default_max_results <= 10: raise ValueError("web_search_default_max_results 必须在 [1, 10] 范围内") if not 1 <= self.web_crawl_default_max_depth <= 3: diff --git a/backend/package/yuxi/services/agent_run_service.py b/backend/package/yuxi/services/agent_run_service.py index 770fe8eb..ba71d3bf 100644 --- a/backend/package/yuxi/services/agent_run_service.py +++ b/backend/package/yuxi/services/agent_run_service.py @@ -259,6 +259,9 @@ async def create_agent_run_view( conversation = await conv_repo.get_conversation_by_thread_id(thread_id) if not conversation or conversation.uid != str(current_uid) or conversation.status == "deleted": raise HTTPException(status_code=404, detail="对话线程不存在") + # conversation.agent_id 由 route 阶段通过 ConversationPort.updateConversationAgentId + # 在 agent-run-enqueue 之前回写,此处仅做一致性校验:不一致即视为路由与 + # 会话绑定错位,拒绝切换已绑定 agent。 if conversation.agent_id != agent_id: raise HTTPException(status_code=409, detail="已有线程已绑定智能体,不能切换") @@ -383,6 +386,18 @@ async def get_agent_run_view(*, run_id: str, current_uid: str, db: AsyncSession) return {"run": run.to_dict()} +async def get_run_uid(run_id: str, db: AsyncSession) -> str: + """查询 run 记录的 uid,用于流式消费前的权限校验。 + + run 不存在时返回空串,由下游自然报错。 + """ + repo = AgentRunRepository(db) + run = await repo.get_run(run_id) + if not run: + return "" + return str(run.uid) + + async def cancel_agent_run_view(*, run_id: str, current_uid: str, db: AsyncSession) -> dict: repo = AgentRunRepository(db) run = await repo.get_run_for_user(run_id, str(current_uid)) diff --git a/backend/package/yuxi/services/operation_log_service.py b/backend/package/yuxi/services/operation_log_service.py index 571a4927..60c05a11 100644 --- a/backend/package/yuxi/services/operation_log_service.py +++ b/backend/package/yuxi/services/operation_log_service.py @@ -1,6 +1,8 @@ from fastapi import Request from sqlalchemy.ext.asyncio import AsyncSession +from yuxi.storage.postgres.manager import pg_manager from yuxi.storage.postgres.models_business import OperationLog +from yuxi.utils import logger async def log_operation( @@ -10,9 +12,21 @@ async def log_operation( details: str | None = None, request: Request | None = None, ) -> None: + """记录操作日志。 + + 使用独立会话写入日志,避免日志写入失败污染业务会话的事务状态。 + ``db`` 参数保留以保持与现有调用方兼容,但实际写入在独立会话中完成。 + """ try: ip_address = request.client.host if request and request.client else None - db.add(OperationLog(user_id=user_id, operation=operation, details=details, ip_address=ip_address)) - await db.commit() - except Exception: - pass + async with pg_manager.get_async_session_context() as session: + session.add( + OperationLog( + user_id=user_id, + operation=operation, + details=details, + ip_address=ip_address, + ) + ) + except Exception as exc: + logger.warning(f"操作日志写入失败(不影响主流程): {operation}, user={user_id}, error={exc}") diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index cd56f788..2a4b2d90 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -8,11 +8,16 @@ import os import time from dataclasses import dataclass, field +from arq import cron from sqlalchemy import select from sqlalchemy.exc import OperationalError -from arq import cron from yuxi.agents.mcp.service import ensure_builtin_mcp_servers_in_db from yuxi.agents.skills.service import init_builtin_skills + +# 业务模块 scheduler handler 注册函数(方案 A:各模块提供 register_scheduler_handlers 钩子) +from yuxi.external_systems.infrastructure.scheduler import ( + register_scheduler_handlers as _external_systems_scheduler_registrar, +) from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository from yuxi.services.chat_service import stream_agent_chat, stream_agent_resume from yuxi.services.run_queue_service import ( @@ -526,10 +531,9 @@ async def _worker_startup(ctx): # 业务模块 scheduler handler 注册函数列表(方案 A)。 # 新增业务模块时,在对应模块的 ``/scheduler.py`` 提供 # ``register_scheduler_handlers(registry: HandlerRegistry) -> None`` 函数, -# 然后在此列表追加一行 import + 引用即可,无需修改 _worker_startup 逻辑。 -# from yuxi.my_module.scheduler import register_scheduler_handlers as _my_module_registrar +# 然后在此列表追加引用即可,无需修改 _worker_startup 逻辑。 _BUSINESS_HANDLER_REGISTRARS: list = [ - # _my_module_registrar, + _external_systems_scheduler_registrar, ] diff --git a/backend/package/yuxi/services/scheduler_tick.py b/backend/package/yuxi/services/scheduler_tick.py index 60e67406..9fe7e592 100644 --- a/backend/package/yuxi/services/scheduler_tick.py +++ b/backend/package/yuxi/services/scheduler_tick.py @@ -10,10 +10,13 @@ - ``run_scheduler_tick(ctx)``:ARQ cron 入口,周期性扫描到期任务并入队执行。 由 ``WorkerSettings.cron_jobs`` 注册,默认每分钟触发(对齐 ``config.scheduler_tick_interval_seconds`` 默认 60s)。 -- ``execute_scheduled_task(ctx, task_id, run_id, triggered_by, scheduled_at=None)``: +- ``execute_scheduled_task(ctx, task_id, run_id, triggered_by, scheduled_at=None, + payload_override=None, operator=None)``: ARQ worker 入口,执行单个到期 / 手动触发的任务。函数名与 ``SchedulerService._EXECUTE_TASK_FUNCTION`` 对齐,由 ``tick`` / ``trigger_task`` - 通过 ``pool.enqueue_job`` 入队。 + 通过 ``pool.enqueue_job`` 入队。``operator`` 为触发人 uid(manual 时由 Admin API + 传入,auto 时为 ``"system"``),用于写入 ``run_log.created_by`` / ``updated_by`` + 审计字段。 - ``register_builtin_handlers(registry)``:注册 scheduler 自带的维护型 handler (``RunLogCleanupHandler`` / ``IdempotencyCleanupHandler``),由 worker 启动钩子调用。 @@ -108,9 +111,7 @@ async def run_scheduler_tick(ctx: WorkerContext) -> None: return async with pg_manager.get_async_session_context() as db: - service = create_worker_scheduler_service( - db, handler_registry=registry, arq_pool=arq_pool - ) + service = create_worker_scheduler_service(db, handler_registry=registry, arq_pool=arq_pool) await service.tick() @@ -120,6 +121,8 @@ async def execute_scheduled_task( run_id: str, triggered_by: str, scheduled_at: str | None = None, + payload_override: dict | None = None, + operator: str | None = None, ) -> None: """ARQ worker 入口:执行单个到期 / 手动触发的任务。 @@ -134,6 +137,11 @@ async def execute_scheduled_task( triggered_by: 触发来源(``auto`` / ``manual``)。 scheduled_at: 本次计划执行时间的 ISO 字符串(tick 触发时为 acquire 前的 原 ``next_run_at``;手动触发时为 None)。 + payload_override: 手动触发时传入的 payload 覆盖值,None 时使用任务定义中的 + payload。仅 ``triggered_by='manual'`` 时有意义。 + operator: 触发人 uid(manual 时为管理员 uid,auto 时为 ``"system"``), + 用于写入 ``run_log.created_by`` / ``updated_by`` 审计字段。None 时 + 回退为 ``"system"``。 """ registry: HandlerRegistry | None = ctx.get(_HANDLER_REGISTRY_CTX_KEY) if registry is None: @@ -162,12 +170,12 @@ async def execute_scheduled_task( ) async with pg_manager.get_async_session_context() as db: - service = create_worker_scheduler_service( - db, handler_registry=registry, arq_pool=arq_pool - ) + service = create_worker_scheduler_service(db, handler_registry=registry, arq_pool=arq_pool) await service.execute_task( task_id=task_id, run_id=run_id, triggered_by=triggered_by, scheduled_at=scheduled_at_dt, + payload_override=payload_override, + operator=operator, ) diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index f1a5fde8..34dfc086 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -6,6 +6,7 @@ from typing import Any from sqlalchemy import ( JSON, Boolean, + CheckConstraint, Column, DateTime, Float, @@ -15,12 +16,11 @@ from sqlalchemy import ( String, Text, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship - from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive - Base = declarative_base() MAX_LOGIN_FAILED_ATTEMPTS = 5 @@ -267,7 +267,7 @@ class Conversation(Base): uid = Column(String(225), index=True, nullable=False, comment="UID(含服务账号 uid,最长 225 字符)") agent_id = Column(String(64), index=True, nullable=False, comment="Agent ID") title = Column(String(255), nullable=True, comment="Conversation title") - status = Column(String(20), default="active", comment="Status: active/archived/deleted") + status = Column(String(20), default="active", index=True, comment="Status: active/archived/deleted") is_pinned = Column(Boolean, default=False, nullable=False, index=True, comment="Is pinned to top") created_at = Column(DateTime, default=utc_now_naive, comment="Creation time") updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive, comment="Update time") @@ -278,9 +278,7 @@ class Conversation(Base): ) # 渠道网关扩展字段(由 ensure_channel_schema() 通过 ALTER TABLE 添加,FR-06/FR-10) - unified_identity_id = Column( - String(64), nullable=True, index=True, comment="统一身份 ID,跨渠道关联时填充(FR-06)" - ) + unified_identity_id = Column(String(64), nullable=True, comment="统一身份 ID,跨渠道关联时填充(FR-06)") channel_session_id = Column(String(64), nullable=True, comment="关联渠道会话 ID(冗余,便于查询)") channel_type = Column(String(32), nullable=True, index=True, comment="来源渠道类型(FR-10)") channel_account_id = Column( @@ -293,7 +291,11 @@ class Conversation(Base): "ConversationStats", back_populates="conversation", uselist=False, cascade="all, delete-orphan" ) - __table_args__ = (Index("idx_conversations_unified_identity", "unified_identity_id"),) + __table_args__ = ( + Index("idx_conversations_unified_identity", "unified_identity_id"), + # analytics account 维度分组聚合专用(JOIN + GROUP BY channel_account_id) + Index("ix_conversations_channel_account_id", "channel_account_id"), + ) def to_dict(self) -> dict[str, Any]: metadata = self.extra_metadata or {} @@ -324,20 +326,35 @@ class Message(Base): id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key") conversation_id = Column( - Integer, ForeignKey("conversations.id"), nullable=False, index=True, comment="Conversation ID" + Integer, + ForeignKey("conversations.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Conversation ID", ) role = Column(String(20), nullable=False, comment="Message role: user/assistant/system/tool") content = Column(Text, nullable=False, comment="Message content") message_type = Column(String(30), default="text", comment="Message type: text/tool_call/tool_result") created_at = Column(DateTime, default=utc_now_naive, comment="Creation time") - token_count = Column(Integer, nullable=True, comment="Token count (optional)") - extra_metadata = Column(JSON, nullable=True, comment="Additional metadata (complete message dump)") + updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive, comment="Last update time") + token_count = Column(Integer, nullable=True, comment="Token count (deprecated: 未填充,保留向后兼容)") + extra_metadata = Column( + JSONB().with_variant(JSON, "sqlite"), + nullable=True, + comment="Additional metadata (complete message dump)", + ) image_content = Column(Text, nullable=True, comment="Base64 encoded image content for multimodal messages") - run_id = Column(String(64), ForeignKey("agent_runs.id"), nullable=True, index=True, comment="Agent run ID") + run_id = Column( + String(64), + ForeignKey("agent_runs.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="Agent run ID", + ) request_id = Column(String(64), nullable=True, index=True, comment="Request ID for idempotency") delivery_status = Column(String(32), nullable=False, default="complete", comment="Message status") - # 渠道网关扩展字段(由 ensure_channel_schema() 通过 ALTER TABLE 添加,FR-09) + # 渠道网关扩展字段(由 ensure_business_schema() 通过 ALTER TABLE 添加,FR-09) # channel_status 与 delivery_status 语义不同:delivery_status 为内部投递状态, # channel_status 为渠道侧状态(sent/delivered/read/recalled/edited),不得合并。 channel_status = Column( @@ -348,15 +365,39 @@ class Message(Base): channel_read_at = Column(DateTime, nullable=True, comment="渠道侧已读时间(FR-09)") channel_recalled_at = Column(DateTime, nullable=True, comment="渠道侧撤回时间(FR-09)") channel_edited_at = Column(DateTime, nullable=True, comment="渠道侧最后编辑时间(FR-09)") - channel_status_history = Column(JSON, nullable=True, comment="渠道侧状态事件历史数组(FR-09)") - operations_history = Column(JSON, nullable=True, comment="消息操作历史数组(FR-12)") + channel_status_history = Column( + JSONB().with_variant(JSON, "sqlite"), + nullable=True, + comment="渠道侧状态事件历史数组(FR-09)", + ) + operations_history = Column( + JSONB().with_variant(JSON, "sqlite"), + nullable=True, + comment="消息操作历史数组(FR-12)", + ) # Relationships conversation = relationship("Conversation", back_populates="messages") tool_calls = relationship("ToolCall", back_populates="message", cascade="all, delete-orphan") feedbacks = relationship("MessageFeedback", back_populates="message", cascade="all, delete-orphan") - __table_args__ = (Index("idx_messages_channel_msg_id", "channel_msg_id"),) + __table_args__ = ( + # 复合索引:覆盖 listMessages / searchMessages 的高频过滤+排序模式 + Index("idx_messages_role_created_at", "role", "created_at"), + Index("idx_messages_channel_status_created_at", "channel_status", "created_at"), + # 单列索引:analytics 时间范围查询专用(不带 role/channel_status 前缀时使用) + Index("ix_messages_created_at", "created_at"), + # CHECK 约束:值域校验,防止应用层 bug 或 raw SQL 写入非法值 + CheckConstraint( + "role IN ('user', 'assistant', 'system', 'tool', 'admin')", + name="ck_messages_role", + ), + CheckConstraint( + "channel_status IS NULL OR channel_status IN " + "('sent', 'delivered', 'read', 'recalled', 'edited', 'pending', 'sending', 'failed')", + name="ck_messages_channel_status", + ), + ) def to_dict(self) -> dict[str, Any]: return { @@ -366,6 +407,7 @@ class Message(Base): "content": self.content, "message_type": self.message_type, "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), "token_count": self.token_count, "metadata": self.extra_metadata or {}, "image_content": self.image_content, @@ -396,7 +438,13 @@ class ToolCall(Base): __tablename__ = "tool_calls" id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key") - message_id = Column(Integer, ForeignKey("messages.id"), nullable=False, index=True, comment="Message ID") + message_id = Column( + Integer, + ForeignKey("messages.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Message ID", + ) langgraph_tool_call_id = Column(String(100), nullable=True, index=True, comment="LangGraph tool_call_id") tool_name = Column(String(100), nullable=False, comment="Tool name") tool_input = Column(JSON, nullable=True, comment="Tool input parameters") @@ -487,7 +535,11 @@ class MessageFeedback(Base): id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key") message_id = Column( - Integer, ForeignKey("messages.id"), nullable=False, index=True, comment="Message ID being rated" + Integer, + ForeignKey("messages.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Message ID being rated", ) uid = Column(String(225), nullable=False, index=True, comment="UID who provided feedback(含服务账号 uid)") rating = Column(String(10), nullable=False, comment="Feedback rating: like or dislike") diff --git a/backend/package/yuxi/storage/postgres/models_channels.py b/backend/package/yuxi/storage/postgres/models_channels.py index ddd34fd5..7b5106be 100644 --- a/backend/package/yuxi/storage/postgres/models_channels.py +++ b/backend/package/yuxi/storage/postgres/models_channels.py @@ -26,6 +26,7 @@ from typing import Any from sqlalchemy import ( BigInteger, Boolean, + CheckConstraint, Column, DateTime, Float, @@ -46,6 +47,9 @@ JSON_VALUE = JSON().with_variant(JSONB, "postgresql") # 幂等记录默认过期时间(小时) IDEMPOTENCY_DEFAULT_TTL_HOURS = 24 +# peer_id 统一最大长度(ChannelSession / ChannelPairing 等对端 ID 列共用) +PEER_ID_MAX_LENGTH = 256 + class ChannelAccount(Base): """渠道账户配置与状态。 @@ -68,7 +72,7 @@ class ChannelAccount(Base): # 业务标识 channel_type = Column(String(32), nullable=False, index=True, comment="渠道类型:feishu/dingtalk/wecom/webchat/...") - account_id = Column(String(128), nullable=False, comment="渠道账户 ID(全局唯一,业务标识)") + account_id = Column(String(128), nullable=False, comment="渠道账户 ID(在 channel_type 范围内唯一,业务标识)") display_name = Column(String(128), nullable=False, comment="展示名称") # 配置 @@ -78,12 +82,23 @@ class ChannelAccount(Base): enabled = Column( Boolean, nullable=False, default=True, comment="管理员启用意图;与 status='disabled' 互为充要,应用层须保证一致" ) - status = Column(String(32), nullable=False, default="active", comment="运行时状态机:active/disabled/degraded") + status = Column( + String(32), + nullable=False, + default="active", + server_default="active", + comment="运行时状态机:active/disabled/degraded", + ) plugin_status = Column( String(32), nullable=False, default="stopped", - comment="插件生命周期状态(FR-32):discovered/parsed/loaded/initialized/starting/running/paused/resuming/stopping/stopped/unloaded/failed", + comment="传输任务运行态(TransportManager 视角):running/stopped/error。" + "注意:插件完整生命周期状态(discovered/resolved/loaded/initialized/" + "started/paused/stopped/unloaded/failed/not_installed/installed)由 " + "PluginRegistry 内存管理,不持久化到此列。两套状态各司其职:本列" + "反映传输任务是否在运行,便于运维快速筛选异常账户;生命周期态反映" + "插件装配进度,通过 plugin/catalog 接口查询。", ) # 接入态状态机(与运行态 status 解耦):pending/configured/verified/online/offline/failed onboarding_status = Column( @@ -121,6 +136,17 @@ class ChannelAccount(Base): UniqueConstraint("channel_type", "account_id", name="uq_channel_accounts_type_account"), Index("ix_channel_accounts_status", "status"), Index("ix_channel_accounts_plugin_status", "plugin_status"), + Index("ix_channel_accounts_enabled", "enabled"), + Index("ix_channel_accounts_onboarding_status", "onboarding_status"), + Index("ix_channel_accounts_stats", "is_deleted", "channel_type", "status", "onboarding_status"), + CheckConstraint( + "status IN ('active', 'disabled', 'degraded')", + name="chk_channel_accounts_status", + ), + CheckConstraint( + "onboarding_status IN ('pending', 'configured', 'verified', 'online', 'offline', 'failed')", + name="chk_channel_accounts_onboarding", + ), ) # ORM 乐观锁:UPDATE 时自动附加 ``WHERE version = ?`` 并自增 version, @@ -178,7 +204,7 @@ class ChannelSession(Base): comment="关联渠道账户 ID(ORM 外键)", ) channel_type = Column(String(32), nullable=False, index=True, comment="渠道类型(冗余,便于查询)") - peer_id = Column(String(256), nullable=False, comment="对端 ID(用户 ID 或群组 ID)") + peer_id = Column(String(PEER_ID_MAX_LENGTH), nullable=False, comment="对端 ID(用户 ID 或群组 ID)") chat_type = Column(String(16), nullable=False, comment="会话类型:p2p / group") # 关联内部会话(可空,首次消息时创建;不设唯一约束,支持跨渠道关联 FR-06) @@ -190,8 +216,8 @@ class ChannelSession(Base): ) # 跨渠道身份与路由 - unified_identity_id = Column(String(64), nullable=True, index=True, comment="统一身份 ID(FR-06 跨渠道关联)") - owner_peer_id = Column(String(256), nullable=True, comment="主会话所有者对端 ID(FR-26)") + unified_identity_id = Column(String(64), nullable=True, comment="统一身份 ID(FR-06 跨渠道关联)") + owner_peer_id = Column(String(PEER_ID_MAX_LENGTH), nullable=True, comment="主会话所有者对端 ID(FR-26)") is_temporary = Column(Boolean, nullable=False, default=False, comment="临时会话标记(FR-27)") last_route_at = Column(DateTime, nullable=True, comment="最近路由更新时间(FR-04/FR-26)") route_match_source = Column( @@ -210,7 +236,7 @@ class ChannelSession(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) + is_deleted = Column(Integer, nullable=False, default=0) deleted_at = Column(DateTime, nullable=True) __table_args__ = ( @@ -224,8 +250,17 @@ class ChannelSession(Base): ), Index("ix_channel_sessions_unified_identity", "unified_identity_id", "channel_type", "account_id"), Index("ix_channel_sessions_conversation", "conversation_id"), + Index("ix_channel_sessions_stats", "is_deleted", "channel_type", "is_temporary"), + Index("ix_channel_sessions_last_message_at", "last_message_at"), + # created_at 索引:analytics getAccountActivity session 维度按 created_at 范围过滤 + Index("ix_channel_sessions_created_at", "created_at"), + # closed_at 索引:findSessionsByFilter 按 status=active/closed 映射到 + # closed_at IS NULL / IS NOT NULL,无索引时全表扫描。 + Index("ix_channel_sessions_closed_at", "closed_at"), ) + __mapper_args__ = {"version_id_col": version} + def to_dict(self) -> dict[str, Any]: """序列化为字典。""" return { @@ -325,6 +360,14 @@ class ChannelUserIdentity(Base): postgresql_using="gin", postgresql_ops={"merged_from": "jsonb_path_ops"}, ), + CheckConstraint( + "confidence IN ('low', 'medium', 'high')", + name="ck_channel_user_identities_confidence", + ), + CheckConstraint( + "identity_type IN ('email', 'phone', 'employee_id')", + name="ck_channel_user_identities_identity_type", + ), ) def to_dict(self) -> dict[str, Any]: @@ -374,7 +417,7 @@ class ChannelPairing(Base): index=True, comment="关联渠道账户 ID(ORM 外键)", ) - peer_id = Column(String(256), nullable=False, comment="渠道侧对端用户 ID") + peer_id = Column(String(PEER_ID_MAX_LENGTH), nullable=False, comment="渠道侧对端用户 ID") peer_name = Column(String(128), nullable=True, comment="对端名称(冗余,便于展示)") # 状态机 @@ -382,6 +425,7 @@ class ChannelPairing(Base): String(32), nullable=False, default="pending", + server_default="pending", index=True, comment="状态:pending/approved/rejected/expired/revoked", ) @@ -395,14 +439,20 @@ class ChannelPairing(Base): requested_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="申请时间") expires_at = Column(DateTime, nullable=False, index=True, comment="过期时间(默认创建时间 + 7 天)") reason = Column(Text, nullable=True, comment="审批原因 / 拒绝原因 / 撤销原因") - version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号") + version = Column(Integer, nullable=False, default=1, server_default="1", comment="乐观锁版本号") # 审计字段 created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) - created_at = Column(DateTime, nullable=False, default=utc_now_naive) - updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive, server_default=text("CURRENT_TIMESTAMP")) + updated_at = Column( + DateTime, + nullable=False, + default=utc_now_naive, + onupdate=utc_now_naive, + server_default=text("CURRENT_TIMESTAMP"), + ) + is_deleted = Column(Integer, nullable=False, default=0, server_default="0", index=True) deleted_at = Column(DateTime, nullable=True) __table_args__ = ( @@ -415,6 +465,11 @@ class ChannelPairing(Base): unique=True, postgresql_where=text("status = 'pending' AND is_deleted = 0"), ), + # CHECK 约束:status 字段仅允许合法枚举值,防止裸 SQL 写入非法状态 + CheckConstraint( + "status IN ('pending', 'approved', 'rejected', 'expired', 'revoked')", + name="ck_channel_pairings_status", + ), ) # 启用 SQLAlchemy 乐观锁:ORM 实例 flush 时自动追加 @@ -470,6 +525,7 @@ class ChannelAuditLog(Base): operator = Column(String(64), nullable=False, index=True, comment="操作人(用户 ID 或 system)") target_channel = Column(String(64), nullable=False, index=True, comment="目标渠道(渠道类型或 global)") target_account = Column(String(128), nullable=True, comment="目标账户 ID(可选)") + target = Column(String(256), nullable=True, index=True, comment="操作目标(渠道:账户:资源路径)") result = Column(String(32), nullable=False, comment="操作结果:success / failed") # 详情 @@ -494,7 +550,7 @@ class ChannelAuditLog(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) + is_deleted = Column(Integer, nullable=False, default=0) deleted_at = Column(DateTime, nullable=True) __table_args__ = ( @@ -515,6 +571,7 @@ class ChannelAuditLog(Base): "operator": self.operator, "target_channel": self.target_channel, "target_account": self.target_account, + "target": self.target, "result": self.result, "params_summary": self.params_summary or {}, "trace_id": self.trace_id, @@ -669,6 +726,10 @@ class ChannelOutboxEntry(Base): # conversation_id 为高频 JOIN/级联列,无索引会触发全表扫描。 Index("ix_channel_outbox_channel_session", "channel_session_id"), Index("ix_channel_outbox_conversation", "conversation_id"), + Index("ix_channel_outbox_stats", "is_deleted", "status"), + Index("ix_channel_outbox_status_updated", "status", "updated_at"), + Index("ix_channel_outbox_channel_msg_id", "channel_msg_id"), + Index("ix_channel_outbox_created_at", "created_at"), ) def to_dict(self) -> dict[str, Any]: @@ -728,8 +789,8 @@ class ChannelRouteBinding(Base): # 主键 id = Column(Integer, primary_key=True, autoincrement=True) - # 业务标识 - binding_id = Column(String(64), nullable=False, index=True, comment="绑定 ID(UUID,业务标识)") + # 业务标识(unique=True 与同模块其他业务标识一致,防止重复导致 MultipleResultsFound) + binding_id = Column(String(64), nullable=False, unique=True, comment="绑定 ID(UUID,业务标识)") # 关联 channel_type = Column(String(32), nullable=False, index=True, comment="渠道类型") @@ -747,12 +808,18 @@ class ChannelRouteBinding(Base): description = Column(String(512), nullable=True, comment="规则说明") # 审计字段 - version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号") + version = Column(Integer, nullable=False, default=1, server_default="1", comment="乐观锁版本号") created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) - created_at = Column(DateTime, nullable=False, default=utc_now_naive) - updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive, server_default=text("CURRENT_TIMESTAMP")) + updated_at = Column( + DateTime, + nullable=False, + default=utc_now_naive, + onupdate=utc_now_naive, + server_default=text("CURRENT_TIMESTAMP"), + ) + is_deleted = Column(Integer, nullable=False, default=0, server_default="0", index=True) deleted_at = Column(DateTime, nullable=True) __table_args__ = ( @@ -776,6 +843,29 @@ class ChannelRouteBinding(Base): ), ) + __mapper_args__ = {"version_id_col": version} + + def to_dict(self) -> dict[str, Any]: + """序列化为字典,与同模块其他模型保持一致。""" + return { + "id": self.id, + "binding_id": self.binding_id, + "channel_type": self.channel_type, + "account_id": self.account_id, + "match_source": self.match_source, + "match_value": self.match_value, + "agent_binding": self.agent_binding, + "enabled": self.enabled, + "description": self.description, + "version": self.version, + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + "is_deleted": self.is_deleted, + "deleted_at": format_utc_datetime(self.deleted_at), + } + class ChannelIdempotency(Base): """写操作幂等记录,支持重复请求重放首次响应。 @@ -858,6 +948,12 @@ class ChannelContentReviewRecord(Base): 持久化 ContentReviewRecord DTO,承载审核结果与命中片段。 独立于 ChannelAuditLog(操作记录)与 ChannelOutboxEntry(投递记录)。 + + ``account_id`` 为 ``String(128)`` 业务标识符(对齐 + ``channel_accounts.account_id``),不使用外键约束以保持与渠道账户解耦 + (``channel_accounts`` 的唯一性为 ``(channel_type, account_id)`` 复合唯一, + 非单列唯一,无法挂单列 FK)。``is_deleted`` 恒为 0,软删除字段保留但未启用, + retention 走物理删除(``delete_old_records``)。 """ __tablename__ = "channel_content_review_records" @@ -885,14 +981,27 @@ class ChannelContentReviewRecord(Base): # 审核结论 verdict = Column(String(16), nullable=False, index=True, comment="审核结论:pass / review / block") confidence = Column(Float, nullable=False, comment="置信度 [0.0, 1.0]") - categories = Column(JSON_VALUE, nullable=False, default=list, comment='命中分类数组(如 ["politics", "violence"])') + categories = Column( + JSON_VALUE, + nullable=False, + default=list, + server_default="[]", + comment='命中分类数组(如 ["politics", "violence"])', + ) detail = Column( - JSON_VALUE, nullable=False, default=list, comment="命中片段数组(含 snippet / position / category / severity)" + JSON_VALUE, + nullable=False, + default=list, + server_default="[]", + comment="命中片段数组(含 snippet / position / category / severity)", ) # 时间与人 - reviewed_at = Column(DateTime, nullable=False, index=True, comment="审核时间戳(UTC)") + reviewed_at = Column( + DateTime, nullable=False, index=True, server_default=text("CURRENT_TIMESTAMP"), comment="审核时间戳(UTC)" + ) reviewer = Column(String(64), nullable=False, comment="审核人(operator.user_id)") + reason = Column(String(512), nullable=True, comment="人工决定原因(覆盖 / 拒绝理由)") # 来源与追踪 source = Column( @@ -900,7 +1009,7 @@ class ChannelContentReviewRecord(Base): nullable=False, comment="审核来源:manual_preview / inbound_pipeline / outbound_pipeline", ) - trace_id = Column(String(64), nullable=True, comment="链路追踪 ID") + trace_id = Column(String(64), nullable=True, index=True, comment="链路追踪 ID") # 决策时长统计(CR-STATS-01 avg_decision_seconds 真实化) # decision_started_at:审核请求开始时间(首次写入时与 reviewed_at 同值) @@ -909,17 +1018,28 @@ class ChannelContentReviewRecord(Base): decision_started_at = Column(DateTime, nullable=True, comment="审核决策开始时间(UTC)") decision_completed_at = Column(DateTime, nullable=True, comment="审核决策完成时间(UTC,人工覆盖时刷新)") + # 乐观锁版本号 + version = Column(Integer, nullable=False, default=1, server_default="1", comment="乐观锁版本号") + # 审计字段(与现有渠道模型统一) created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) - created_at = Column(DateTime, nullable=False, default=utc_now_naive) - updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) + created_at = Column(DateTime, nullable=False, default=utc_now_naive, server_default=text("CURRENT_TIMESTAMP")) + updated_at = Column( + DateTime, + nullable=False, + default=utc_now_naive, + onupdate=utc_now_naive, + server_default=text("CURRENT_TIMESTAMP"), + ) is_deleted = Column(Integer, nullable=False, default=0, index=True) deleted_at = Column(DateTime, nullable=True) # 复合索引(按渠道+账户查询、按时间范围查询) # 注:source 不作为查询过滤条件(仅聚合 case when 使用),不建索引; # reviewed_at 是高频时间范围扫描字段,单列索引直接支持 stats/analytics 趋势分桶。 + # (channel_type, reviewed_at) 复合索引:analytics 查询同时按渠道+时间范围过滤, + # 单次索引扫描即可定位,避免回表过滤 channel_type。 __table_args__ = ( Index( "ix_channel_content_review_channel_account", @@ -930,8 +1050,42 @@ class ChannelContentReviewRecord(Base): "ix_channel_content_review_reviewed_at", "reviewed_at", ), + Index( + "ix_channel_content_review_channel_time", + "channel_type", + "reviewed_at", + ), + Index( + "ix_channel_content_review_list", + "channel_type", + "account_id", + "verdict", + "reviewed_at", + ), + CheckConstraint( + "verdict IN ('pass', 'review', 'block')", + name="chk_channel_content_review_verdict", + ), + CheckConstraint( + "source IN ('manual_preview', 'inbound_pipeline', 'outbound_pipeline')", + name="chk_channel_content_review_source", + ), + CheckConstraint( + "resource_type IN ('message_text', 'message_attachment', 'user_profile')", + name="chk_channel_content_review_resource_type", + ), + CheckConstraint( + "confidence >= 0.0 AND confidence <= 1.0", + name="chk_channel_content_review_confidence", + ), ) + # 启用 SQLAlchemy 乐观锁:ORM 实例 flush 时自动追加 + # ``WHERE id = ? AND version = ?`` 并自增 version,并发状态转换冲突时 + # 抛 ``StaleDataError``,由适配器层翻译为 ``ConflictError``。 + # 与 ``ChannelAccount`` / ``ChannelPairing`` 保持一致的并发控制风格。 + __mapper_args__ = {"version_id_col": version} + def to_dict(self) -> dict[str, Any]: """序列化为字典(datetime 统一用 format_utc_datetime)。""" return { @@ -947,10 +1101,97 @@ class ChannelContentReviewRecord(Base): "detail": self.detail, "reviewed_at": format_utc_datetime(self.reviewed_at), "reviewer": self.reviewer, + "reason": self.reason, "source": self.source, "trace_id": self.trace_id, "decision_started_at": format_utc_datetime(self.decision_started_at), "decision_completed_at": format_utc_datetime(self.decision_completed_at), + "version": self.version, + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + "is_deleted": self.is_deleted, + "deleted_at": format_utc_datetime(self.deleted_at), + } + + +class ChannelPlugin(Base): + """渠道插件安装记录(PLG-INSTALL 持久化)。 + + 持久化插件文件级安装元数据(installed_at / install_source / + install_version / manifest 快照),使系统重启后可恢复安装上下文, + 并提供运维审计能力(谁、何时、从哪安装了哪个版本)。 + + 与 ``PluginRegistry``(内存注册表)的关系: + - ``PluginRegistry`` 管理运行时生命周期状态(discovered/started/...), + 重启后从文件系统重新 discover。 + - ``ChannelPlugin`` 表持久化安装元数据,重启后不丢失,用于审计与 + ``catalog`` / ``getPlugin`` 接口补充安装时间字段。 + - 两套数据各司其职:本表不存储生命周期状态(内存态),仅存储 + 安装元数据与 manifest 快照。 + + 软删除策略:uninstall 时 ``is_deleted=1``,保留历史记录供审计追溯。 + """ + + __tablename__ = "channel_plugins" + + # 主键 + id = Column(Integer, primary_key=True, autoincrement=True) + + # 业务标识 + plugin_id = Column(String(128), nullable=False, unique=True, comment="插件全局唯一标识(manifest.manifest.id)") + name = Column(String(128), nullable=False, comment="插件显示名称") + plugin_version = Column(String(64), nullable=False, comment="插件版本(语义化版本)") + channel_type = Column(String(32), nullable=False, comment="绑定渠道类型") + + # 安装元数据 + install_source = Column(String(512), nullable=True, comment="安装来源标识(文件路径 / URL / 包名)") + install_version = Column(String(64), nullable=True, comment="安装时指定的版本号(可能与 version 不同)") + installed_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="文件级安装时间") + + # manifest 快照(JSONB,存储完整清单用于重启后恢复上下文) + manifest_snapshot = Column( + JSON_VALUE, + nullable=False, + default=dict, + comment="安装时的 manifest 完整快照(含 capabilities / config_schema / depends 等)", + ) + + # 当前状态摘要(与 PluginRegistry 内存状态同步,仅用于运维查询参考) + # 注意:此字段为 best-effort 同步,权威状态以 PluginRegistry 为准 + last_known_state = Column(String(32), nullable=True, comment="最近已知生命周期状态(参考用,权威状态在内存)") + last_error = Column(Text, nullable=True, comment="最近一次失败错误信息(参考用)") + + version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号") + + # 审计字段 + created_by = Column(String(64), nullable=True) + updated_by = Column(String(64), nullable=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive) + updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) + is_deleted = Column(Integer, nullable=False, default=0, index=True) + deleted_at = Column(DateTime, nullable=True) + + __table_args__ = (Index("ix_channel_plugins_channel_type", "channel_type"),) + + __mapper_args__ = {"version_id_col": version} + + def to_dict(self) -> dict[str, Any]: + """序列化为字典。""" + return { + "id": self.id, + "plugin_id": self.plugin_id, + "name": self.name, + "plugin_version": self.plugin_version, + "channel_type": self.channel_type, + "install_source": self.install_source, + "install_version": self.install_version, + "installed_at": format_utc_datetime(self.installed_at), + "manifest_snapshot": self.manifest_snapshot or {}, + "last_known_state": self.last_known_state, + "last_error": self.last_error, + "version": self.version, "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), diff --git a/backend/package/yuxi/storage/postgres/models_external.py b/backend/package/yuxi/storage/postgres/models_external.py index 9c3ce69f..9334f4f5 100644 --- a/backend/package/yuxi/storage/postgres/models_external.py +++ b/backend/package/yuxi/storage/postgres/models_external.py @@ -12,6 +12,7 @@ from typing import Any from sqlalchemy import ( BigInteger, Boolean, + CheckConstraint, Column, DateTime, ForeignKey, @@ -21,11 +22,22 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + and_, func, select, + text, +) +from sqlalchemy import ( + Enum as SAEnum, ) from sqlalchemy.dialects.postgresql import JSON, JSONB from sqlalchemy.orm import column_property +from yuxi.external_systems.framework.runtime.enums import ( + ExecutionCaller, + ExecutionOperation, + ExecutionStatus, + HealthStatus, +) from yuxi.storage.postgres.models_business import Base from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive @@ -43,14 +55,19 @@ class ExternalToolConfig(Base): # 基础信息 slug = Column(String(128), nullable=False, unique=True, index=True, comment="工具唯一标识") name = Column(String(128), nullable=False, comment="展示名称") - description = Column(Text, nullable=False, comment="工具描述(给 LLM 看)") + description = Column(Text, nullable=True, default="", comment="工具描述(给 LLM 看)") category = Column(String(64), nullable=False, default="default", comment="展示分类") adapter_type = Column(String(32), nullable=False, default="http", comment="适配器类型:http/grpc/...") enabled = Column(Boolean, nullable=False, default=True, comment="是否启用") # 通用执行配置 timeout = Column(Integer, nullable=False, default=30, comment="超时时间(秒)") - retry_policy = Column(JSON_VALUE, nullable=False, default=dict, comment="重试策略") + retry_policy = Column( + JSON_VALUE, + nullable=False, + default=dict, + comment="重试策略(adapter 层实现重试,executor 层不重试)", + ) auth_type = Column(String(32), nullable=False, default="none", comment="认证类型") auth_config = Column(JSON_VALUE, nullable=True, comment="认证配置(敏感字段加密存储)") @@ -65,23 +82,49 @@ class ExternalToolConfig(Base): index=True, comment="所属系统 ID(为空时自包含运行)", ) + source_type = Column( + String(64), + nullable=True, + index=True, + comment="工具来源类型(继承自所属系统的 source_type,用于溯源)", + ) # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - Index("ix_ext_tool_configs_system_id_enabled", "system_id", "enabled"), - Index("ix_ext_tool_configs_adapter_type_enabled", "adapter_type", "enabled"), - Index("ix_ext_tool_configs_category_enabled", "category", "enabled"), + Index( + "ix_ext_tool_configs_system_id_enabled", + "system_id", + "enabled", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_tool_configs_adapter_type_enabled", + "adapter_type", + "enabled", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_tool_configs_category_enabled", + "category", + "enabled", + postgresql_where=text("is_deleted = false"), + ), ) def to_dict(self) -> dict[str, Any]: - """序列化为字典;敏感字段的加解密由 persistence 层承接。""" + """序列化为字典;敏感字段的加解密由 persistence 层承接。 + + .. warning:: + ``auth_config`` / ``adapter_config`` 返回的是**密文**,仅供调试使用。 + 业务层应通过 ``orm_to_external_tool`` mapper 获取解密后的数据。 + """ return { "id": self.id, "slug": self.slug, @@ -125,12 +168,12 @@ class ExternalToolConfigVersion(Base): snapshot = Column(JSON_VALUE, nullable=False, comment="完整工具配置快照(敏感字段已加密)") # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = (UniqueConstraint("tool_slug", "version_id", name="uq_ext_tool_config_versions_tool_version"),) @@ -158,7 +201,7 @@ class ExternalAdapterAsset(Base): # 资产基础信息 adapter_type = Column(String(32), nullable=False, index=True, comment="适配器类型:http/grpc/soap/...") - asset_type = Column(String(32), nullable=False, comment="资产类型:proto/reflection/wsdl/openapi/...") + asset_type = Column(String(32), nullable=False, index=True, comment="资产类型:proto/reflection/wsdl/openapi/...") name = Column(String(256), nullable=False, comment="资产名称(通常取自上传文件名)") content = Column(LargeBinary, nullable=False, comment="资产原始内容(二进制)") size = Column(Integer, default=0, comment="内容字节数") @@ -170,27 +213,80 @@ class ExternalAdapterAsset(Base): nullable=False, default="active", index=True, - comment="资产状态:active/invalid", + comment="资产状态:active/invalid/pending", ) status_message = Column(String(512), nullable=True, comment="状态说明,如解析失败原因") # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive, index=True) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False) + deleted_at = Column(DateTime, nullable=True, index=True) + + __table_args__ = ( + # status 取值约束(与 AssetStatus Literal 对齐) + CheckConstraint( + "status IN ('active', 'invalid', 'pending')", + name="ck_ext_adapter_assets_status", + ), + # checksum 部分唯一索引:未软删除记录中 checksum 唯一,避免重复上传 + Index( + "uq_ext_adapter_assets_checksum_active", + "checksum", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + # 复合索引:按适配器类型 + 状态 / 适配器类型 + 资产类型筛选(部分索引,仅覆盖未删除记录) + Index( + "ix_ext_adapter_assets_adapter_type_status", + "adapter_type", + "status", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_adapter_assets_adapter_type_asset_type", + "adapter_type", + "asset_type", + postgresql_where=text("is_deleted = false"), + ), + ) + + def to_dict(self) -> dict[str, Any]: + """序列化为 API 响应字典;``content`` 为二进制原值,列表场景应使用 defer 排除。""" + return { + "id": self.id, + "adapter_type": self.adapter_type, + "asset_type": self.asset_type, + "name": self.name, + "size": self.size or 0, + "checksum": self.checksum, + "status": self.status, + "status_message": self.status_message, + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + } class ExternalSystem(Base): """外部系统一级实体。""" __tablename__ = "ext_systems" + __table_args__ = ( + Index("ix_ext_systems_adapter_enabled_deleted", "adapter_type", "enabled", "is_deleted"), + Index("ix_ext_systems_category_enabled_deleted", "category", "enabled", "is_deleted"), + Index("ix_ext_systems_source_type_deleted", "source_type", "is_deleted"), + ) # 主键 id = Column(Integer, primary_key=True, autoincrement=True) + # 乐观锁(并发更新保护) + version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号") + # 基础信息 slug = Column(String(128), nullable=False, unique=True, index=True, comment="系统唯一标识") name = Column(String(128), nullable=False, comment="展示名称") @@ -198,6 +294,7 @@ class ExternalSystem(Base): category = Column(String(64), nullable=False, default="default", comment="展示分类") adapter_type = Column(String(32), nullable=False, comment="适配器类型") source_type = Column(String(64), nullable=True, comment="渠道 source_type,用于统一操作分派") + integration_key = Column(String(64), nullable=True, index=True, comment="厂商集成目录 key,关联系统与集成元数据") enabled = Column(Boolean, nullable=False, default=True, comment="是否启用") # 连接与认证配置 @@ -214,25 +311,32 @@ class ExternalSystem(Base): # 通用执行配置 timeout = Column(Integer, nullable=False, default=30, comment="超时时间(秒)") - retry_policy = Column(JSON_VALUE, nullable=False, default=dict, comment="重试策略") + retry_policy = Column( + JSON_VALUE, + nullable=False, + default=dict, + comment="重试策略(adapter 层实现重试,executor 层不重试)", + ) # 审计字段 created_by = Column(String(64), nullable=True) updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) def to_dict(self) -> dict[str, Any]: return { "id": self.id, + "version": self.version, "slug": self.slug, "name": self.name, "description": self.description, "category": self.category, "adapter_type": self.adapter_type, "source_type": self.source_type, + "integration_key": self.integration_key, "enabled": self.enabled, "connection_config": self.connection_config or {}, "auth_type": self.auth_type, @@ -284,18 +388,20 @@ class ExternalSystemEnvironment(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( UniqueConstraint("system_id", "env_key", name="uq_ext_system_environments_system_env"), - # 每个外部系统只能有一个默认环境 + # 每个外部系统只能有一个默认环境(仅限未软删除记录,避免软删除默认环境后无法设置新默认) Index( "uq_ext_system_environments_default", "system_id", unique=True, - postgresql_where=is_default.is_(True), + postgresql_where=and_(is_default.is_(True), is_deleted.is_(False)), ), + # 跨系统查询同 env_key 的环境时使用(复合唯一索引 (system_id, env_key) 不满足最左前缀) + Index("ix_ext_system_environments_env_key", "env_key"), ) def to_dict(self) -> dict[str, Any]: @@ -316,9 +422,11 @@ class ExternalSystemEnvironment(Base): # 外部系统环境数:使用关联子查询,避免列表查询时出现 N+1 问题 +# 过滤软删除记录,确保 env_count 与实际未删除环境数一致 ExternalSystem.env_count = column_property( select(func.count(ExternalSystemEnvironment.id)) .where(ExternalSystemEnvironment.system_id == ExternalSystem.id) + .where(ExternalSystemEnvironment.is_deleted.is_(False)) .correlate(ExternalSystem) .scalar_subquery() ) @@ -356,10 +464,26 @@ class ExternalSystemToken(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) - __table_args__ = (UniqueConstraint("system_id", "env_key", name="uq_ext_system_tokens_system_env"),) + __table_args__ = ( + UniqueConstraint("system_id", "env_key", name="uq_ext_system_tokens_system_env"), + # 列表查询主路径:按系统 + 环境 + 失效状态过滤 + Index( + "ix_ext_system_tokens_system_env_invalidated", + "system_id", + "env_key", + "is_invalidated", + postgresql_where=text("is_deleted = false"), + ), + # 即将过期查询:按 expires_at 范围扫描,过滤未失效记录 + Index( + "ix_ext_system_tokens_expires_at_active", + "expires_at", + postgresql_where=text("is_deleted = false AND is_invalidated = false"), + ), + ) def to_dict(self) -> dict[str, Any]: """序列化为字典;不返回 Token 明文,仅返回是否存在。 @@ -414,7 +538,11 @@ class ExternalSystemMetric(Base): timeout_calls = Column(Integer, nullable=False, default=0) # 延迟统计 - p99_latency_ms = Column(Integer, nullable=True, comment="当前桶的近似峰值延迟") + p99_latency_ms = Column( + Integer, + nullable=True, + comment="当前桶的近似 p99 延迟(upsert 冲突时取 GREATEST 保留峰值,聚合时取各桶 MAX)", + ) latency_sum_ms = Column( BigInteger, nullable=False, default=0, comment="当前桶调用延迟总和(毫秒),用于原子累加计算真实平均值" ) @@ -431,16 +559,23 @@ class ExternalSystemMetric(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) - __table_args__ = (UniqueConstraint("system_id", "env_key", "bucket_at", name="uq_ext_system_metrics_bucket"),) + __table_args__ = ( + UniqueConstraint("system_id", "env_key", "bucket_at", name="uq_ext_system_metrics_bucket"), + Index( + "ix_ext_system_metrics_bucket", + "bucket_at", + postgresql_where=text("is_deleted = false"), + ), + ) def to_dict(self) -> dict[str, Any]: """序列化为 API 响应字典;avg_latency_ms 由 latency_sum_ms/latency_count 计算,避免存储不一致。""" latency_count = self.latency_count or 0 latency_sum_ms = self.latency_sum_ms or 0 - avg_latency_ms = round(latency_sum_ms / latency_count, 2) if latency_count > 0 else 0.0 + avg_latency_ms = round(latency_sum_ms / latency_count, 2) if latency_count > 0 else None return { "id": self.id, "system_id": self.system_id, @@ -511,8 +646,8 @@ class ExternalSystemAuditLog(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive, index=True) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( Index("ix_ext_system_audit_logs_system_id_created_at", "system_id", "created_at"), @@ -556,24 +691,50 @@ class ExternalToolExecution(Base): correlation_id = Column(String(64), nullable=True, index=True, comment="业务关联 ID,如 Agent 运行 ID") # 关联关系 - system_id = Column(Integer, nullable=False, index=True, comment="所属系统 ID") + # system_id 允许为空:工具级路径(tool.system_id is None)不关联具体系统, + # 避免用负数占位违反 FK 约束。查询时需处理 NULL 语义。 + system_id = Column( + Integer, + ForeignKey("ext_systems.id", ondelete="CASCADE"), + nullable=True, + index=True, + comment="所属系统 ID(工具级路径为 NULL)", + ) env_key = Column(String(32), nullable=False, default="default", comment="环境标识") - tool_slug = Column(String(128), nullable=False, index=True, comment="工具 slug") + tool_slug = Column( + String(128), + ForeignKey("ext_tool_configs.slug", ondelete="CASCADE", onupdate="CASCADE"), + nullable=False, + index=True, + comment="工具 slug", + ) # 调用方信息 - caller = Column(String(32), nullable=False, comment="调用方:agent/user/workflow/simulate/sync_job/retry") + caller = Column( + SAEnum(ExecutionCaller, name="execution_caller", native_enum=False), + nullable=False, + comment="调用方:agent/user/workflow/simulate/sync_job/retry", + ) caller_id = Column(String(64), nullable=True, comment="调用方实体 ID") # 执行状态与时间 status = Column( - String(32), nullable=False, index=True, comment="pending/running/success/failed/timeout/throttled/cancelled" + SAEnum(ExecutionStatus, name="execution_status", native_enum=False), + nullable=False, + index=True, + comment="pending/running/success/failed/timeout/throttled/cancelled", ) started_at = Column(DateTime, nullable=False, index=True, comment="开始时间") ended_at = Column(DateTime, nullable=True, comment="结束时间") duration_ms = Column(Integer, nullable=True, comment="耗时毫秒") # 操作类型 - operation = Column(String(32), nullable=False, default="execute", comment="execute/health_check/test/simulate") + operation = Column( + SAEnum(ExecutionOperation, name="execution_operation", native_enum=False), + nullable=False, + default=ExecutionOperation.EXECUTE, + comment="execute/health_check/test/simulate", + ) # 请求/响应摘要(已脱敏) request_summary = Column(JSON_VALUE, nullable=False, default=dict, comment="请求摘要(已脱敏)") @@ -586,17 +747,39 @@ class ExternalToolExecution(Base): tags = Column(JSON_VALUE, nullable=False, default=dict, comment="业务标签") # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - Index("ix_ext_tool_executions_system_started", "system_id", "started_at"), - Index("ix_ext_tool_executions_tool_started", "tool_slug", "started_at"), - Index("ix_ext_tool_executions_status_started", "status", "started_at"), + Index( + "ix_ext_tool_executions_system_started", + "system_id", + "started_at", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_tool_executions_tool_started", + "tool_slug", + "started_at", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_tool_executions_status_started", + "status", + "started_at", + postgresql_where=text("is_deleted = false"), + ), + # GIN 索引:加速 tags JSONB 的 @> 包含查询(tag_key/tag_value 过滤) + Index( + "ix_ext_tool_executions_tags_gin", + "tags", + postgresql_using="gin", + postgresql_ops={"tags": "jsonb_path_ops"}, + ), ) def to_dict(self, *, tool_name: str | None = None) -> dict[str, Any]: @@ -646,7 +829,13 @@ class ExternalToolExecutionTrace(Base): id = Column(BigInteger, primary_key=True, autoincrement=True) # 关联关系 - execution_id = Column(String(64), nullable=False, unique=True, comment="关联执行记录") + execution_id = Column( + String(64), + ForeignKey("ext_tool_executions.execution_id", ondelete="CASCADE"), + nullable=False, + unique=True, + comment="关联执行记录", + ) trace_id = Column(String(64), nullable=False, index=True, comment="调用链路 ID") # 链路数据 @@ -654,12 +843,12 @@ class ExternalToolExecutionTrace(Base): total_duration_ms = Column(Integer, nullable=True, comment="总耗时") # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False) + deleted_at = Column(DateTime, nullable=True, index=True) def to_dict(self) -> dict[str, Any]: """序列化为 API 响应字典。""" @@ -698,9 +887,9 @@ class ExternalSystemHealthCheck(Base): # 探测结果 health_status = Column( - String(32), + SAEnum(HealthStatus, name="health_status", native_enum=False), nullable=False, - default="not_checked", + default=HealthStatus.NOT_CHECKED, comment="健康状态:healthy/auth_failure/connectivity_issue/not_checked", ) duration_ms = Column(Integer, nullable=True, comment="探测耗时毫秒") @@ -714,13 +903,36 @@ class ExternalSystemHealthCheck(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - UniqueConstraint("system_id", "env_key", name="uq_ext_system_health_checks_system_env"), - Index("ix_ext_system_health_checks_status", "health_status"), - Index("ix_ext_system_health_checks_checked_at", "checked_at"), + # 部分唯一索引:仅对未删除记录生效,避免软删除后 upsert 冲突(与 ext_system_environments 等表一致) + Index( + "uq_ext_system_health_checks_system_env", + "system_id", + "env_key", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + # 部分索引:查询均带 is_deleted=false 过滤,与其他 ext_* 表保持一致 + Index( + "ix_ext_system_health_checks_status", + "health_status", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_system_health_checks_checked_at", + "checked_at", + postgresql_where=text("is_deleted = false"), + ), + # 复合索引:按系统取最新记录(get_latest_by_system 常用路径) + Index( + "ix_ext_system_health_checks_system_checked", + "system_id", + "checked_at", + postgresql_where=text("is_deleted = false"), + ), ) def to_dict(self) -> dict[str, Any]: @@ -757,7 +969,7 @@ class ExternalWebhookSubscription(Base): id = Column(Integer, primary_key=True, autoincrement=True) # 基础信息 - slug = Column(String(128), nullable=False, unique=True, index=True, comment="订阅唯一标识,用于回调路径") + slug = Column(String(128), nullable=False, comment="订阅唯一标识,用于回调路径") name = Column(String(128), nullable=False, comment="展示名称") description = Column(Text, nullable=False, default="", comment="订阅描述") @@ -816,12 +1028,44 @@ class ExternalWebhookSubscription(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( Index("ix_ext_webhook_subscriptions_system_env_event", "system_id", "env_key", "source_event_type"), Index("ix_ext_webhook_subscriptions_status_enabled", "status", "enabled"), + Index("ix_ext_webhook_subscriptions_last_received_at", "last_received_at"), + Index( + "ix_ext_webhook_subscriptions_renewal_scan", + "status", + "auto_renew", + "expires_at", + ), + # 幂等键:slug / callback_path 仅对未软删除记录唯一,避免软删除后无法重建同名订阅 + Index( + "uq_ext_webhook_subscriptions_slug", + "slug", + unique=True, + postgresql_where=(is_deleted.is_(False)), + ), + Index( + "uq_ext_webhook_subscriptions_callback_path", + "callback_path", + unique=True, + postgresql_where=(is_deleted.is_(False)), + ), + CheckConstraint( + "status IN ('pending', 'active', 'paused', 'expired', 'disabled')", + name="ck_ext_webhook_subscriptions_status", + ), + CheckConstraint( + "secret_algorithm IN ('hmac_sha256', 'hmac_sha1', 'md5')", + name="ck_ext_webhook_subscriptions_secret_algorithm", + ), + CheckConstraint( + "target_handler_type IN ('agent', 'workflow', 'webhook_forward')", + name="ck_ext_webhook_subscriptions_target_handler_type", + ), ) def to_dict(self) -> dict[str, Any]: @@ -920,12 +1164,21 @@ class ExternalWebhookEvent(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - UniqueConstraint("subscription_id", "external_event_id", name="uq_ext_webhook_events_sub_ext_event"), + # 幂等去重:仅对未软删除记录生效,避免软删除记录永久占用 external_event_id + Index( + "uq_ext_webhook_events_sub_ext_event", + "subscription_id", + "external_event_id", + unique=True, + postgresql_where=text("is_deleted = false"), + ), Index("ix_ext_webhook_events_received_status", "received_at", "processing_status"), + Index("ix_ext_webhook_events_sub_payload_hash", "subscription_id", "payload_hash"), + Index("ix_ext_webhook_events_sub_status_received", "subscription_id", "processing_status", "received_at"), ) def to_dict(self) -> dict[str, Any]: @@ -1016,12 +1269,36 @@ class ExternalSystemQuotaUsage(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( UniqueConstraint("system_id", "env_key", "quota_key", name="uq_ext_system_quota_usage_system_env_key"), Index("ix_ext_system_quota_usage_window_end", "window_end"), + CheckConstraint( + "source IN ('response_header', 'manual', 'config')", + name="ck_ext_system_quota_usage_source", + ), + CheckConstraint( + "quota_window IN ('daily', 'hourly', 'rolling_24h', 'minute', 'custom')", + name="ck_ext_system_quota_usage_quota_window", + ), + CheckConstraint( + "source_header_strategy IN ('remaining', 'used', 'limit', 'fraction_used_limit')", + name="ck_ext_system_quota_usage_source_header_strategy", + ), + CheckConstraint( + "warning_threshold >= 0 AND warning_threshold <= 100", + name="ck_ext_system_quota_usage_warning_threshold", + ), + CheckConstraint( + "critical_threshold >= 0 AND critical_threshold <= 100", + name="ck_ext_system_quota_usage_critical_threshold", + ), + CheckConstraint( + "window_start IS NULL OR window_end IS NULL OR window_start < window_end", + name="ck_ext_system_quota_usage_window_range", + ), ) def to_dict(self) -> dict[str, Any]: @@ -1130,8 +1407,8 @@ class ExternalSystemAlert(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( Index("ix_ext_system_alerts_status_severity_triggered", "status", "severity", "triggered_at"), @@ -1242,21 +1519,80 @@ class ExternalSecretRotationPolicy(Base): comment="策略状态:active/rotating/overdue/failed/disabled", ) + # 乐观锁(并发轮换保护) + version = Column( + Integer, + nullable=False, + default=1, + comment="乐观锁版本号,状态转换时 CAS 校验", + ) + # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - UniqueConstraint( + # 仅对未软删除记录做唯一性约束 + Index( + "uq_ext_secret_rotation_policies_system_env_secret", "system_id", "env_key", "secret_type", "secret_name", - name="uq_ext_secret_rotation_policies_system_env_secret", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + # 调度扫描常用过滤路径 + Index( + "ix_ext_secret_rotation_policies_status_next_rotation", + "status", + "next_rotation_at", + ), + Index( + "ix_ext_secret_rotation_policies_system_status_next_rotation", + "system_id", + "status", + "next_rotation_at", + ), + CheckConstraint( + "rotation_mode IN ('auto', 'manual')", + name="ck_ext_secret_rotation_policies_rotation_mode", + ), + CheckConstraint( + "status IN ('active', 'rotating', 'overdue', 'failed', 'disabled')", + name="ck_ext_secret_rotation_policies_status", + ), + CheckConstraint( + "last_rotation_status IS NULL OR last_rotation_status IN ('success', 'failed', 'skipped')", + name="ck_ext_secret_rotation_policies_last_rotation_status", + ), + CheckConstraint( + "secret_type IN ('client_secret', 'api_key', 'cert', 'password', 'connection_string')", + name="ck_ext_secret_rotation_policies_secret_type", + ), + CheckConstraint( + "rotation_interval_days > 0", + name="ck_ext_secret_rotation_policies_rotation_interval_days", + ), + CheckConstraint( + "notify_before_days >= 0", + name="ck_ext_secret_rotation_policies_notify_before_days", + ), + CheckConstraint( + "max_rotation_retries >= 0", + name="ck_ext_secret_rotation_policies_max_rotation_retries", + ), + CheckConstraint( + "current_retry_count >= 0", + name="ck_ext_secret_rotation_policies_current_retry_count", + ), + CheckConstraint( + "rotation_count >= 0", + name="ck_ext_secret_rotation_policies_rotation_count", ), ) @@ -1282,6 +1618,189 @@ class ExternalSecretRotationPolicy(Base): "max_rotation_retries": self.max_rotation_retries, "current_retry_count": self.current_retry_count, "status": self.status, + "version": self.version, + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + } + + +class ExternalSecret(Base): + """外部系统具名密钥元数据表。 + + 作为 ``ExternalSystem.secret_refs`` JSONB 中密钥 key 的引用索引, + 提供引用完整性校验与软删除保护,不存储密钥值本身(仍由 secret_refs 托管)。 + 创建密钥轮换策略时若引用不存在则自动注册。 + """ + + __tablename__ = "ext_secrets" + + # 主键 + id = Column(Integer, primary_key=True, autoincrement=True) + + # 关联关系 + system_id = Column( + Integer, + ForeignKey("ext_systems.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="所属外部系统 ID", + ) + env_key = Column(String(32), nullable=False, default="default", comment="环境标识") + + # 密钥定义 + secret_type = Column( + String(32), + nullable=False, + comment="密钥类型:client_secret/api_key/cert/password/connection_string", + ) + secret_name = Column(String(128), nullable=False, comment="密钥展示名称") + secret_ref_key = Column( + String(128), + nullable=False, + comment="对应 ExternalSystem.secret_refs 中的 key", + ) + + # 状态 + status = Column( + String(32), + nullable=False, + default="active", + comment="状态:active/inactive", + ) + + # 乐观锁 + version = Column(Integer, nullable=False, default=1, comment="乐观锁版本号") + + # 审计字段 + created_by = Column(String(64), nullable=False, default="system") + updated_by = Column(String(64), nullable=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive) + updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) + + __table_args__ = ( + # 同一系统/环境下 secret_ref_key 唯一(未删除) + Index( + "uq_ext_secrets_system_env_ref", + "system_id", + "env_key", + "secret_ref_key", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + CheckConstraint( + "secret_type IN ('client_secret', 'api_key', 'cert', 'password', 'connection_string')", + name="ck_ext_secrets_secret_type", + ), + CheckConstraint( + "status IN ('active', 'inactive')", + name="ck_ext_secrets_status", + ), + ) + + def to_dict(self) -> dict[str, Any]: + """序列化为 API 响应字典。""" + return { + "id": self.id, + "system_id": self.system_id, + "env_key": self.env_key, + "secret_type": self.secret_type, + "secret_name": self.secret_name, + "secret_ref_key": self.secret_ref_key, + "status": self.status, + "version": self.version, + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + } + + +class ExternalSecretRotationRecord(Base): + """密钥轮换执行历史记录。 + + 记录每次轮换尝试(成功/失败/跳过),用于审计、排障与合规追溯。 + """ + + __tablename__ = "ext_secret_rotation_records" + + # 主键 + id = Column(Integer, primary_key=True, autoincrement=True) + + # 关联关系 + policy_id = Column( + Integer, + ForeignKey("ext_secret_rotation_policies.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="所属轮换策略 ID", + ) + secret_id = Column( + Integer, + ForeignKey("ext_secrets.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="轮换的密钥元数据 ID", + ) + + # 执行信息 + attempted_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="尝试时间") + status = Column( + String(32), + nullable=False, + comment="执行结果:success/failed/skipped", + ) + mode = Column( + String(32), + nullable=False, + comment="执行模式:auto/manual", + ) + error = Column(Text, nullable=True, comment="失败时的错误信息") + performed_by = Column(String(64), nullable=True, comment="执行人 UID(手动时填写)") + old_secret_version = Column(String(64), nullable=True, comment="旧密钥版本标识") + new_secret_version = Column(String(64), nullable=True, comment="新密钥版本标识") + + # 审计字段 + created_by = Column(String(64), nullable=False, default="system") + updated_by = Column(String(64), nullable=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive) + updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) + + __table_args__ = ( + # 按策略查询历史常用路径 + Index( + "ix_ext_secret_rotation_records_policy_attempted", + "policy_id", + "attempted_at", + ), + CheckConstraint( + "status IN ('success', 'failed', 'skipped')", + name="ck_ext_secret_rotation_records_status", + ), + CheckConstraint( + "mode IN ('auto', 'manual')", + name="ck_ext_secret_rotation_records_mode", + ), + ) + + def to_dict(self) -> dict[str, Any]: + """序列化为 API 响应字典。""" + return { + "id": self.id, + "policy_id": self.policy_id, + "secret_id": self.secret_id, + "attempted_at": format_utc_datetime(self.attempted_at), + "status": self.status, + "mode": self.mode, + "error": self.error, + "performed_by": self.performed_by, + "old_secret_version": self.old_secret_version, + "new_secret_version": self.new_secret_version, "created_by": self.created_by, "updated_by": self.updated_by, "created_at": format_utc_datetime(self.created_at), @@ -1306,14 +1825,14 @@ class ExternalToolAccessRule(Base): # 关联关系 tool_slug = Column( String(128), - ForeignKey("ext_tool_configs.slug", ondelete="CASCADE", onupdate="CASCADE"), + ForeignKey("ext_tool_configs.slug", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="工具 slug(为空表示系统下所有工具)", ) system_id = Column( Integer, - ForeignKey("ext_systems.id", ondelete="CASCADE"), + ForeignKey("ext_systems.id", ondelete="SET NULL"), nullable=True, index=True, comment="所属系统 ID(为空表示全局规则)", @@ -1340,17 +1859,61 @@ class ExternalToolAccessRule(Base): description = Column(Text, nullable=True, comment="规则说明") # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - Index("ix_ext_tool_access_rules_tool_enabled", "tool_slug", "enabled"), - Index("ix_ext_tool_access_rules_principal", "principal_type", "principal_id"), - Index("ix_ext_tool_access_rules_system_enabled", "system_id", "enabled"), + # 防止重复创建相同语义规则(未软删除记录间唯一) + Index( + "uq_ext_tool_access_rules_scope", + "system_id", + "tool_slug", + "principal_type", + "principal_id", + "env_key", + "effect", + unique=True, + postgresql_where=text("is_deleted = false"), + postgresql_nulls_not_distinct=True, + ), + # 列表页常用过滤路径 + Index( + "ix_ext_tool_access_rules_system_env", + "system_id", + "env_key", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_tool_access_rules_tool_env", + "tool_slug", + "env_key", + postgresql_where=text("is_deleted = false"), + ), + # 评估查询常用路径:先按主体定位,再按启用/过期过滤 + Index( + "ix_ext_tool_access_rules_eval_principal", + "principal_type", + "principal_id", + "enabled", + "expires_at", + postgresql_where=text("is_deleted = false"), + ), + CheckConstraint( + "principal_type IN ('user', 'role', 'department', 'agent', 'service_account')", + name="ck_ext_tool_access_rules_principal_type", + ), + CheckConstraint( + "effect IN ('allow', 'deny')", + name="ck_ext_tool_access_rules_effect", + ), + CheckConstraint( + "priority >= 0", + name="ck_ext_tool_access_rules_priority", + ), ) def to_dict(self) -> dict[str, Any]: @@ -1413,7 +1976,7 @@ class ExternalToolTestCase(Base): # 调度与启用 enabled = Column(Boolean, nullable=False, default=True, comment="是否启用") - schedule_cron = Column(String(64), nullable=True, index=True, comment="运行计划 cron 表达式,为空表示手动运行") + schedule_cron = Column(String(64), nullable=True, comment="运行计划 cron 表达式,为空表示手动运行") # 上次运行结果 last_run_at = Column(DateTime, nullable=True, comment="上次运行时间") @@ -1429,16 +1992,56 @@ class ExternalToolTestCase(Base): alert_threshold = Column(Integer, nullable=False, default=3, comment="连续失败几次后告警") # 审计字段 - created_by = Column(String(64), nullable=True) + created_by = Column(String(64), nullable=False, default="system") updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - Index("ix_ext_tool_test_cases_tool_enabled", "tool_slug", "enabled"), - Index("ix_ext_tool_test_cases_last_run_status", "last_run_status"), + Index( + "ix_ext_tool_test_cases_tool_enabled", + "tool_slug", + "enabled", + postgresql_where=text("is_deleted = false"), + ), + # 列表按运行状态/期望状态/环境筛选(仅未删除记录) + Index( + "ix_ext_tool_test_cases_last_run_status", + "last_run_status", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_tool_test_cases_expected_status", + "expected_status", + postgresql_where=text("is_deleted = false"), + ), + Index( + "ix_ext_tool_test_cases_env_key", + "env_key", + postgresql_where=text("is_deleted = false"), + ), + # 列表按 cron 表达式精确筛选(仅未删除记录) + Index( + "ix_ext_tool_test_cases_schedule_cron", + "schedule_cron", + postgresql_where=text("is_deleted = false and schedule_cron is not null"), + ), + # 调度器查询:已启用且配置了 cron 的用例 + Index( + "ix_ext_tool_test_cases_scheduled", + "enabled", + "schedule_cron", + postgresql_where=text("is_deleted = false and schedule_cron is not null"), + ), + # 告警扫描:开启告警且连续失败次数大于 0 的用例 + Index( + "ix_ext_tool_test_cases_failing", + "consecutive_failures", + "alert_on_failure", + postgresql_where=text("is_deleted = false and alert_on_failure = true and consecutive_failures > 0"), + ), ) def to_dict(self) -> dict[str, Any]: @@ -1503,12 +2106,21 @@ class ExternalNotificationChannel(Base): updated_by = Column(String(64), nullable=True) created_at = Column(DateTime, nullable=False, default=utc_now_naive) updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive) - is_deleted = Column(Integer, nullable=False, default=0, index=True) - deleted_at = Column(DateTime, nullable=True) + is_deleted = Column(Boolean, nullable=False, default=False, index=True) + deleted_at = Column(DateTime, nullable=True, index=True) __table_args__ = ( - Index("ix_ext_notification_channels_type_enabled", "channel_type", "enabled"), + Index( + "ix_ext_notification_channels_type_enabled", + "channel_type", + "enabled", + postgresql_where=text("is_deleted = false"), + ), Index("ix_ext_notification_channels_name", "name"), + CheckConstraint( + "channel_type IN ('email', 'webhook', 'slack', 'feishu', 'dingtalk')", + name="ck_ext_notification_channels_channel_type", + ), ) def to_dict(self) -> dict[str, Any]: diff --git a/backend/package/yuxi/storage/postgres/models_scheduler.py b/backend/package/yuxi/storage/postgres/models_scheduler.py index 4b0fdb48..7201f982 100644 --- a/backend/package/yuxi/storage/postgres/models_scheduler.py +++ b/backend/package/yuxi/storage/postgres/models_scheduler.py @@ -166,6 +166,20 @@ class ScheduledTask(Base): unique=True, postgresql_where=text("is_deleted = 0"), ), + # 列表查询复合索引:常见路径 WHERE is_deleted=0 ORDER BY created_at DESC + # 覆盖软删除过滤 + 按创建时间排序,避免 filesort + Index("ix_scheduled_tasks_list", "is_deleted", "created_at"), + # 回收站查询索引:WHERE is_deleted=1 ORDER BY deleted_at DESC + Index("ix_scheduled_tasks_recycle", "is_deleted", "deleted_at"), + # 工作台状态计数与异常查询部分索引:WHERE is_deleted=0 + # 覆盖 count_by_status 的 GROUP BY status 及异常任务按 status 过滤, + # 部分索引仅包含未删除行,体积小且命中率高 + Index( + "ix_scheduled_tasks_status_alive", + "status", + "consecutive_errors", + postgresql_where=text("is_deleted = 0"), + ), ) def to_dict(self) -> dict[str, Any]: @@ -216,6 +230,13 @@ class ScheduledTaskRunLog(Base): # 关联(字符串引用,非外键) task_id = Column(String(64), nullable=False, comment="关联 scheduled_tasks.task_id;非外键,任务删除后日志保留") + handler_name = Column( + String(128), + nullable=True, + index=True, + comment="handler 名称冗余字段(写入时从 task 快照);nullable 兼容存量数据," + "支持跨任务按 handler 过滤与 reclaim RETURNING 补录日聚合统计", + ) run_id = Column( String(64), nullable=False, unique=True, comment="单次执行唯一标识(UUID),用于幂等控制与 ARQ _job_id 关联" ) @@ -251,6 +272,8 @@ class ScheduledTaskRunLog(Base): __table_args__ = ( Index("ix_scheduled_task_run_logs_task_started", "task_id", "started_at"), Index("ix_scheduled_task_run_logs_started", "started_at"), + # 复合索引:健康检查 / reclaim / list_by_status 均按 status + started_at 过滤 + Index("ix_scheduled_task_run_logs_status_started", "status", "started_at"), ) def to_dict(self) -> dict[str, Any]: @@ -258,6 +281,7 @@ class ScheduledTaskRunLog(Base): return { "id": self.id, "task_id": self.task_id, + "handler_name": self.handler_name, "run_id": self.run_id, "triggered_by": self.triggered_by, "status": self.status, diff --git a/backend/server/utils/error_handlers.py b/backend/server/utils/error_handlers.py index 7e25a418..c9b9fba3 100644 --- a/backend/server/utils/error_handlers.py +++ b/backend/server/utils/error_handlers.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json import logging from fastapi import Request @@ -56,8 +57,9 @@ async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSON logging.getLogger("uvicorn.error").exception("unhandled exception", exc_info=exc) return build_error_response( status_code=500, - code="INTERNAL", - message="Internal server error", + code="INTERNAL_SERVER_ERROR", + message="服务器内部错误,请稍后重试", + details={"hint": "如果问题持续,请联系管理员并提供 trace_id"}, ) @@ -75,8 +77,15 @@ async def request_validation_error_handler( ``pydantic.ValidationError``,再由 FastAPI 转为 ``RequestValidationError``, 本处理器统一捕获并转换为项目格式。状态码保持 422(FastAPI 标准), 仅统一响应结构。 + + ``exc.errors()`` 返回的 error dict 中 ``ctx`` 字段可能携带非 JSON 可序列化 + 对象(如 ``field_validator`` 抛出 ``ValueError`` 时 ctx 为 + ``{"error": }``)。直接放入 ``JSONResponse`` 会触发 + ``TypeError``,被 ``unhandled_exception_handler`` 兜底为 500。此处通过 + ``json.dumps(..., default=str)`` 将非序列化对象降级为字符串,确保响应 + 始终可序列化。 """ - errors = exc.errors() + errors = json.loads(json.dumps(exc.errors(), default=str)) first = errors[0] if errors else {} loc = first.get("loc", []) field = ".".join(str(part) for part in loc) if loc else "" diff --git a/web/src/router/index.js b/web/src/router/index.js index 8f9993fc..c75f041e 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -2,517 +2,602 @@ import { createRouter, createWebHistory } from 'vue-router' import AppLayout from '@/layouts/AppLayout.vue' import BlankLayout from '@/layouts/BlankLayout.vue' import { useUserStore } from '@/stores/user' +import { validateNavigationAgainstRoutes } from '@/components/channels/layout/navigation' +import { validateNavigationAgainstRoutes as validateEsNavigationAgainstRoutes } from '@/components/external-systems/layout/navigation' import { useAgentStore } from '@/stores/agent' import { sanitizeRedirect } from '@/utils/oidcAutoStart' +const routes = [ + { + path: '/', + name: 'main', + component: BlankLayout, + children: [ + { + path: '', + name: 'Home', + component: () => import('../views/HomeView.vue'), + meta: { keepAlive: true, requiresAuth: false } + } + ] + }, + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue'), + meta: { requiresAuth: false } + }, + { + path: '/auth/oidc/callback', // oidc登录回调页面 + name: 'OIDCCallback', + component: () => import('@/views/OIDCCallbackView.vue'), + meta: { public: true } + }, + { + path: '/agent', + name: 'AgentMain', + component: AppLayout, + children: [ + { + path: '', + name: 'AgentComp', + component: () => import('../views/AgentView.vue'), + meta: { keepAlive: true, requiresAuth: true } + }, + { + path: ':thread_id', + name: 'AgentCompWithThreadId', + component: () => import('../views/AgentView.vue'), + meta: { keepAlive: true, requiresAuth: true } + } + ] + }, + { + path: '/workspace', + name: 'workspace', + component: AppLayout, + children: [ + { + path: '', + name: 'WorkspaceComp', + component: () => import('../views/WorkspaceView.vue'), + meta: { keepAlive: true, requiresAuth: true } + } + ] + }, + { + path: '/dashboard', + name: 'dashboard', + component: AppLayout, + children: [ + { + path: '', + name: 'DashboardComp', + component: () => import('../views/DashboardView.vue'), + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } + } + ] + }, + { + path: '/model-manage', + name: 'model-manage', + component: AppLayout, + children: [ + { + path: '', + name: 'ModelManageComp', + component: () => import('../views/ModelManageView.vue'), + meta: { keepAlive: false, requiresAuth: true } + } + ] + }, + { + path: '/extensions', + name: 'extensions', + component: AppLayout, + children: [ + { + path: '', + name: 'ExtensionsComp', + component: () => import('../views/ExtensionsView.vue'), + meta: { + keepAlive: false, + requiresAuth: true + }, + children: [ + { + path: 'knowledgebase/:kbId', + name: 'ExtensionKnowledgeBaseDetail', + component: () => import('../views/DataBaseInfoView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true + } + }, + { + path: 'mcp/:slug', + name: 'ExtensionMcpDetail', + component: () => import('../components/extensions/McpDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true + } + }, + { + path: 'skill/:slug', + name: 'ExtensionSkillDetail', + component: () => import('../components/extensions/SkillDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true + } + } + ] + } + ] + }, + { + path: '/external-systems', + name: 'external-systems', + component: AppLayout, + children: [ + { + path: '', + name: 'ExternalSystemsMain', + component: () => import('../views/ExternalSystemsView.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'EsOverview', + component: () => import('../components/external-systems/layout/OverviewView.vue'), + meta: { keepAlive: false, requiresAuth: true } + }, + // ===== 资产管理(5 模块)===== + { + path: 'asset-management/systems', + name: 'EsSystems', + component: () => + import('../components/external-systems/asset-management/system/SystemListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'asset-management/systems/:id', + name: 'EsSystemDetail', + component: () => + import('../components/external-systems/asset-management/system/SystemDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true, + parentModule: 'EsSystems' + } + }, + { + path: 'asset-management/tools', + name: 'EsTools', + component: () => + import('../components/external-systems/asset-management/tool/ToolListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'asset-management/tools/:id', + name: 'EsToolDetail', + component: () => + import('../components/external-systems/asset-management/tool/ToolDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true, + parentModule: 'EsTools' + } + }, + { + path: 'asset-management/environments', + name: 'EsEnvironments', + component: () => + import('../components/external-systems/asset-management/environment/EnvironmentListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'asset-management/assets', + name: 'EsAssets', + component: () => + import('../components/external-systems/asset-management/adapter-asset/AssetListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'asset-management/assets/:id', + name: 'EsAssetDetail', + component: () => + import('../components/external-systems/asset-management/adapter-asset/AssetDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true, + parentModule: 'EsAssets' + } + }, + { + path: 'asset-management/import-export', + name: 'EsImportExport', + component: () => + import('../components/external-systems/asset-management/import-export/ImportExportView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + // ===== 运行监控(5 模块)===== + { + path: 'runtime-monitoring/executions', + name: 'EsExecutions', + component: () => + import('../components/external-systems/runtime-monitoring/execution/ExecutionListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'runtime-monitoring/executions/:id', + name: 'EsExecutionDetail', + component: () => + import('../components/external-systems/runtime-monitoring/execution/ExecutionDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true, + parentModule: 'EsExecutions' + } + }, + { + path: 'runtime-monitoring/tokens', + name: 'EsTokens', + component: () => + import('../components/external-systems/runtime-monitoring/token/TokenListView.vue'), + // 后端 GET 端点用 get_required_user(普通用户可查看),写操作由按钮级权限控制 + meta: { keepAlive: true, requiresAuth: true } + }, + { + path: 'runtime-monitoring/audit-logs', + name: 'EsAuditLogs', + component: () => + import('../components/external-systems/runtime-monitoring/audit-log/AuditLogListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'runtime-monitoring/audit-logs/config-changes', + name: 'EsAuditLogConfigChanges', + component: () => + import('../components/external-systems/runtime-monitoring/audit-log/ConfigChangeHistoryView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true, + parentModule: 'EsAuditLogs' + } + }, + { + path: 'runtime-monitoring/health-checks', + name: 'EsHealthChecks', + component: () => + import('../components/external-systems/runtime-monitoring/health-check/HealthCheckView.vue'), + meta: { keepAlive: false, requiresAuth: true } + }, + { + path: 'runtime-monitoring/metrics', + name: 'EsMetrics', + component: () => + import('../components/external-systems/runtime-monitoring/metrics/MetricsQueryView.vue'), + // 后端 metric 查询端点用 get_required_user(普通用户可查看),写操作(清理桶)入口在系统详情页 + meta: { keepAlive: false, requiresAuth: true } + }, + // ===== 治理管控(5 模块)===== + { + path: 'governance-control/quotas', + name: 'EsQuotas', + component: () => + import('../components/external-systems/governance-control/quota/QuotaListView.vue'), + // 后端 GET 端点用 get_required_user(普通用户可查看),写操作在组件内通过 isAdmin 控制 + meta: { keepAlive: true, requiresAuth: true } + }, + { + path: 'governance-control/alerts', + name: 'EsAlerts', + component: () => + import('../components/external-systems/governance-control/alert/AlertListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'governance-control/secret-rotations', + name: 'EsKeyRotations', + component: () => + import('../components/external-systems/governance-control/key-rotation/KeyRotationListView.vue'), + // 后端 GET 端点(list/stats/due/overdue/expiring/detail/notify-config/precheck)用 get_required_user(普通用户可查看与预检),写操作在组件内通过 isAdmin 控制 + meta: { keepAlive: true, requiresAuth: true } + }, + { + path: 'governance-control/access-rules', + name: 'EsAccessRules', + component: () => + import('../components/external-systems/governance-control/access-rule/AccessRuleListView.vue'), + meta: { keepAlive: true, requiresAuth: true } + }, + { + path: 'governance-control/test-cases', + name: 'EsTestCases', + component: () => + import('../components/external-systems/governance-control/test-case/TestCaseListView.vue'), + // 后端 GET/run/batch-execute/export 用 get_required_user(普通用户可查看与执行),写操作在组件内通过 isAdmin 控制 + meta: { keepAlive: true, requiresAuth: true } + }, + // ===== 集成配置(7 模块)===== + { + path: 'integration-config/adapter-metadata', + name: 'EsAdapterMetadata', + component: () => + import('../components/external-systems/integration-config/adapter-metadata/AdapterMetadataListView.vue'), + meta: { keepAlive: true, requiresAuth: true } + }, + { + path: 'integration-config/vendor-integrations', + name: 'EsVendorIntegrations', + component: () => + import('../components/external-systems/integration-config/vendor-integration/VendorListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'integration-config/vendor-integrations/:key', + name: 'EsVendorIntegrationDetail', + component: () => + import('../components/external-systems/integration-config/vendor-integration/VendorDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true, + parentModule: 'EsVendorIntegrations' + } + }, + { + path: 'integration-config/integration-generator', + name: 'EsIntegrationGenerator', + component: () => + import('../components/external-systems/integration-config/integration-generator/IntegrationGeneratorView.vue'), + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'integration-config/webhooks/subscriptions', + name: 'EsWebhookSubscriptions', + component: () => + import('../components/external-systems/integration-config/webhook/WebhookSubscriptionListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'integration-config/webhooks/events', + name: 'EsWebhookEvents', + component: () => + import('../components/external-systems/integration-config/webhook/WebhookEventListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'integration-config/auth-plugins', + name: 'EsAuthPlugins', + component: () => + import('../components/external-systems/integration-config/auth-plugin/AuthPluginView.vue'), + // 原型 §2.1.6:总览页对所有角色可见(user/admin/superadmin),仅「校验」按钮需 admin + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: false } + }, + { + path: 'integration-config/notification-channels', + name: 'EsNotificationChannels', + component: () => + import('../components/external-systems/integration-config/notification-channel/NotificationChannelListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'integration-config/recycle-bin', + name: 'EsRecycleBin', + component: () => + import('../components/external-systems/integration-config/recycle-bin/RecycleBinListView.vue'), + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } + } + ] + } + ] + }, + { + path: '/scheduler', + component: AppLayout, + children: [ + { + path: '', + name: 'scheduler', + component: () => import('../views/SchedulerView.vue'), + children: [ + { + path: '', + name: 'scheduler-dashboard', + component: () => import('../components/scheduler/dashboard/DashboardView.vue'), + meta: { requiresAuth: true, keepAlive: true } + }, + { + path: 'tasks', + name: 'scheduler-tasks', + component: () => import('../components/scheduler/task/TaskListView.vue'), + meta: { requiresAuth: true, keepAlive: true } + }, + { + path: 'run-logs', + name: 'scheduler-run-logs', + component: () => import('../components/scheduler/run-log/RunLogListView.vue'), + meta: { requiresAuth: true, keepAlive: true } + }, + { + path: 'stats', + name: 'scheduler-stats', + component: () => import('../components/scheduler/stats/StatsView.vue'), + meta: { requiresAuth: true, keepAlive: true } + }, + { + path: 'handlers', + name: 'scheduler-handlers', + component: () => import('../components/scheduler/handler/HandlerSummaryView.vue'), + meta: { requiresAuth: true, keepAlive: true } + }, + { + path: 'tasks/deleted', + name: 'scheduler-recycle-bin', + component: () => import('../components/scheduler/recycle-bin/RecycleBinView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, keepAlive: true } + }, + { + path: 'ops', + name: 'scheduler-ops', + component: () => import('../components/scheduler/ops/OpsCenterView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, keepAlive: true } + } + ] + } + ] + }, + { + path: '/channels', + component: AppLayout, + meta: { requiresAuth: true, requiresAdmin: true }, + children: [ + { + path: '', + name: 'ChannelsMain', + component: () => import('../views/ChannelsView.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, + children: [ + // ===== 接入与运维 ===== + { + path: '', + name: 'ChannelsDashboard', + component: () => import('../components/channels/access-ops/DashboardView.vue'), + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'accounts', + name: 'ChannelsAccounts', + component: () => import('../components/channels/access-ops/AccountListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'accounts/:id', + name: 'ChannelsAccountDetail', + component: () => import('../components/channels/access-ops/AccountDetailView.vue'), + meta: { + keepAlive: false, + requiresAuth: true, + requiresAdmin: true, + parentModule: 'ChannelsAccounts' + } + }, + { + path: 'route-bindings', + name: 'ChannelsRouteBindings', + component: () => import('../components/channels/access-ops/RouteBindingView.vue'), + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'capabilities', + name: 'ChannelsCapabilities', + component: () => import('../components/channels/access-ops/CapabilityQueryView.vue'), + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } + }, + // ===== 通讯与会话 ===== + { + path: 'sessions', + name: 'ChannelsSessions', + component: () => import('../components/channels/communication/SessionListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'messages', + name: 'ChannelsMessages', + component: () => import('../components/channels/communication/MessageListView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'outbox', + name: 'ChannelsOutbox', + component: () => import('../components/channels/communication/OutboxMonitorView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'directory', + name: 'ChannelsDirectory', + component: () => import('../components/channels/communication/DirectoryQueryView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + // ===== 安全治理 ===== + { + path: 'pairings', + name: 'ChannelsPairings', + component: () => import('../components/channels/security/PairingApprovalView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'allowlists', + name: 'ChannelsAllowlists', + component: () => import('../components/channels/security/AllowlistView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'content-reviews', + name: 'ChannelsContentReviews', + component: () => import('../components/channels/security/ContentReviewView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + // ===== 系统治理 ===== + { + path: 'configs', + name: 'ChannelsConfigs', + component: () => + import('../components/channels/system-governance/ConfigManageView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'plugins', + name: 'ChannelsPlugins', + component: () => import('../components/channels/access-ops/PluginManageView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + { + path: 'audit-logs', + name: 'ChannelsAuditLogs', + component: () => import('../components/channels/system-governance/AuditLogView.vue'), + meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } + }, + // ===== 数据洞察 ===== + { + path: 'analytics', + name: 'ChannelsAnalytics', + component: () => import('../components/channels/analytics/AnalyticsView.vue'), + meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } + } + ] + } + ] + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('../views/EmptyView.vue'), + meta: { requiresAuth: false } + } +] + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'main', - component: BlankLayout, - children: [ - { - path: '', - name: 'Home', - component: () => import('../views/HomeView.vue'), - meta: { keepAlive: true, requiresAuth: false } - } - ] - }, - { - path: '/login', - name: 'login', - component: () => import('../views/LoginView.vue'), - meta: { requiresAuth: false } - }, - { - path: '/auth/oidc/callback', // oidc登录回调页面 - name: 'OIDCCallback', - component: () => import('@/views/OIDCCallbackView.vue'), - meta: { public: true } - }, - { - path: '/agent', - name: 'AgentMain', - component: AppLayout, - children: [ - { - path: '', - name: 'AgentComp', - component: () => import('../views/AgentView.vue'), - meta: { keepAlive: true, requiresAuth: true } - }, - { - path: ':thread_id', - name: 'AgentCompWithThreadId', - component: () => import('../views/AgentView.vue'), - meta: { keepAlive: true, requiresAuth: true } - } - ] - }, - { - path: '/workspace', - name: 'workspace', - component: AppLayout, - children: [ - { - path: '', - name: 'WorkspaceComp', - component: () => import('../views/WorkspaceView.vue'), - meta: { keepAlive: true, requiresAuth: true } - } - ] - }, - { - path: '/dashboard', - name: 'dashboard', - component: AppLayout, - children: [ - { - path: '', - name: 'DashboardComp', - component: () => import('../views/DashboardView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - } - ] - }, - { - path: '/model-manage', - name: 'model-manage', - component: AppLayout, - children: [ - { - path: '', - name: 'ModelManageComp', - component: () => import('../views/ModelManageView.vue'), - meta: { keepAlive: false, requiresAuth: true } - } - ] - }, - { - path: '/extensions', - name: 'extensions', - component: AppLayout, - children: [ - { - path: '', - name: 'ExtensionsComp', - component: () => import('../views/ExtensionsView.vue'), - meta: { - keepAlive: false, - requiresAuth: true - }, - children: [ - { - path: 'knowledgebase/:kbId', - name: 'ExtensionKnowledgeBaseDetail', - component: () => import('../views/DataBaseInfoView.vue'), - meta: { - keepAlive: false, - requiresAuth: true, - requiresAdmin: true - } - }, - { - path: 'mcp/:slug', - name: 'ExtensionMcpDetail', - component: () => import('../components/extensions/McpDetailView.vue'), - meta: { - keepAlive: false, - requiresAuth: true, - requiresAdmin: true - } - }, - { - path: 'skill/:slug', - name: 'ExtensionSkillDetail', - component: () => import('../components/extensions/SkillDetailView.vue'), - meta: { - keepAlive: false, - requiresAuth: true - } - } - ] - } - ] - }, - { - path: '/external-systems', - name: 'external-systems', - component: AppLayout, - children: [ - { - path: '', - name: 'ExternalSystemsOverview', - component: () => import('../views/ExternalSystemsView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - // ===== 资产管理(5 模块)===== - { - path: 'asset-management/systems', - name: 'EsSystems', - component: () => import('../components/external-systems/asset-management/system/SystemListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'asset-management/systems/:id', - name: 'EsSystemDetail', - component: () => import('../components/external-systems/asset-management/system/SystemDetailView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'asset-management/tools', - name: 'EsTools', - component: () => import('../components/external-systems/asset-management/tool/ToolListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'asset-management/tools/:id', - name: 'EsToolDetail', - component: () => import('../components/external-systems/asset-management/tool/ToolDetailView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'asset-management/environments', - name: 'EsEnvironments', - component: () => import('../components/external-systems/asset-management/environment/EnvironmentListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'asset-management/assets', - name: 'EsAssets', - component: () => import('../components/external-systems/asset-management/adapter-asset/AssetListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'asset-management/assets/:id', - name: 'EsAssetDetail', - component: () => import('../components/external-systems/asset-management/adapter-asset/AssetDetailView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'asset-management/import-export', - name: 'EsImportExport', - component: () => import('../components/external-systems/asset-management/import-export/ImportExportView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - // ===== 运行监控(5 模块)===== - { - path: 'runtime-monitoring/executions', - name: 'EsExecutions', - component: () => import('../components/external-systems/runtime-monitoring/execution/ExecutionListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'runtime-monitoring/executions/:id', - name: 'EsExecutionDetail', - component: () => import('../components/external-systems/runtime-monitoring/execution/ExecutionDetailView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'runtime-monitoring/tokens', - name: 'EsTokens', - component: () => import('../components/external-systems/runtime-monitoring/token/TokenListView.vue'), - // 后端 GET 端点用 get_required_user(普通用户可查看),写操作由按钮级权限控制 - meta: { keepAlive: true, requiresAuth: true } - }, - { - path: 'runtime-monitoring/audit-logs', - name: 'EsAuditLogs', - component: () => import('../components/external-systems/runtime-monitoring/audit-log/AuditLogListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'runtime-monitoring/audit-logs/config-changes', - name: 'EsAuditLogConfigChanges', - component: () => import('../components/external-systems/runtime-monitoring/audit-log/ConfigChangeHistoryView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'runtime-monitoring/health-checks', - name: 'EsHealthChecks', - component: () => import('../components/external-systems/runtime-monitoring/health-check/HealthCheckView.vue'), - meta: { keepAlive: false, requiresAuth: true } - }, - { - path: 'runtime-monitoring/metrics', - name: 'EsMetrics', - component: () => import('../components/external-systems/runtime-monitoring/metrics/MetricsQueryView.vue'), - // 后端 metric 查询端点用 get_required_user(普通用户可查看),写操作(清理桶)入口在系统详情页 - meta: { keepAlive: false, requiresAuth: true } - }, - // ===== 治理管控(5 模块)===== - { - path: 'governance-control/quotas', - name: 'EsQuotas', - component: () => import('../components/external-systems/governance-control/quota/QuotaListView.vue'), - // 后端 GET 端点用 get_required_user(普通用户可查看),写操作在组件内通过 isAdmin 控制 - meta: { keepAlive: true, requiresAuth: true } - }, - { - path: 'governance-control/alerts', - name: 'EsAlerts', - component: () => import('../components/external-systems/governance-control/alert/AlertListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'governance-control/secret-rotations', - name: 'EsKeyRotations', - component: () => import('../components/external-systems/governance-control/key-rotation/KeyRotationListView.vue'), - // 后端 GET 端点(list/stats/due/overdue/expiring/detail/notify-config/precheck)用 get_required_user(普通用户可查看与预检),写操作在组件内通过 isAdmin 控制 - meta: { keepAlive: true, requiresAuth: true } - }, - { - path: 'governance-control/access-rules', - name: 'EsAccessRules', - component: () => import('../components/external-systems/governance-control/access-rule/AccessRuleListView.vue'), - meta: { keepAlive: true, requiresAuth: true } - }, - { - path: 'governance-control/test-cases', - name: 'EsTestCases', - component: () => import('../components/external-systems/governance-control/test-case/TestCaseListView.vue'), - // 后端 GET/run/batch-execute/export 用 get_required_user(普通用户可查看与执行),写操作在组件内通过 isAdmin 控制 - meta: { keepAlive: true, requiresAuth: true } - }, - // ===== 集成配置(7 模块)===== - { - path: 'integration-config/adapter-metadata', - name: 'EsAdapterMetadata', - component: () => import('../components/external-systems/integration-config/adapter-metadata/AdapterMetadataListView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'integration-config/vendor-integrations', - name: 'EsVendorIntegrations', - component: () => import('../components/external-systems/integration-config/vendor-integration/VendorListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'integration-config/vendor-integrations/:key', - name: 'EsVendorIntegrationDetail', - component: () => import('../components/external-systems/integration-config/vendor-integration/VendorDetailView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'integration-config/integration-generator', - name: 'EsIntegrationGenerator', - component: () => import('../components/external-systems/integration-config/integration-generator/IntegrationGeneratorView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'integration-config/webhooks/subscriptions', - name: 'EsWebhookSubscriptions', - component: () => import('../components/external-systems/integration-config/webhook/WebhookSubscriptionListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'integration-config/webhooks/events', - name: 'EsWebhookEvents', - component: () => import('../components/external-systems/integration-config/webhook/WebhookEventListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'integration-config/auth-plugins', - name: 'EsAuthPlugins', - component: () => import('../components/external-systems/integration-config/auth-plugin/AuthPluginView.vue'), - // 原型 §2.1.6:总览页对所有角色可见(user/admin/superadmin),仅「校验」按钮需 admin - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: false } - }, - { - path: 'integration-config/notification-channels', - name: 'EsNotificationChannels', - component: () => import('../components/external-systems/integration-config/notification-channel/NotificationChannelListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'integration-config/recycle-bin', - name: 'EsRecycleBin', - component: () => import('../components/external-systems/integration-config/recycle-bin/RecycleBinListView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - } - ] - }, - { - path: '/scheduler', - component: AppLayout, - children: [ - { - path: '', - name: 'scheduler', - component: () => import('../views/SchedulerView.vue'), - children: [ - { - path: '', - name: 'scheduler-dashboard', - component: () => import('../components/scheduler/dashboard/DashboardView.vue'), - meta: { requiresAuth: true, keepAlive: true } - }, - { - path: 'tasks', - name: 'scheduler-tasks', - component: () => import('../components/scheduler/task/TaskListView.vue'), - meta: { requiresAuth: true, keepAlive: true } - }, - { - path: 'run-logs', - name: 'scheduler-run-logs', - component: () => import('../components/scheduler/run-log/RunLogListView.vue'), - meta: { requiresAuth: true, keepAlive: true } - }, - { - path: 'stats', - name: 'scheduler-stats', - component: () => import('../components/scheduler/stats/StatsView.vue'), - meta: { requiresAuth: true, keepAlive: true } - }, - { - path: 'handlers', - name: 'scheduler-handlers', - component: () => import('../components/scheduler/handler/HandlerSummaryView.vue'), - meta: { requiresAuth: true, keepAlive: true } - }, - { - path: 'tasks/deleted', - name: 'scheduler-recycle-bin', - component: () => import('../components/scheduler/recycle-bin/RecycleBinView.vue'), - meta: { requiresAuth: true, requiresAdmin: true, keepAlive: true } - }, - { - path: 'ops', - name: 'scheduler-ops', - component: () => import('../components/scheduler/ops/OpsCenterView.vue'), - meta: { requiresAuth: true, requiresAdmin: true, keepAlive: true } - } - ] - } - ] - }, - { - path: '/channels', - component: AppLayout, - children: [ - { - path: '', - name: 'ChannelsMain', - component: () => import('../views/ChannelsView.vue'), - children: [ - // ===== 接入与运维 ===== - { - path: '', - name: 'ChannelsDashboard', - component: () => import('../components/channels/access-ops/DashboardView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'accounts', - name: 'ChannelsAccounts', - component: () => import('../components/channels/access-ops/AccountListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'accounts/:id', - name: 'ChannelsAccountDetail', - component: () => import('../components/channels/access-ops/AccountDetailView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'route-bindings', - name: 'ChannelsRouteBindings', - component: () => import('../components/channels/access-ops/RouteBindingView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'capabilities', - name: 'ChannelsCapabilities', - component: () => import('../components/channels/access-ops/CapabilityQueryView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - // ===== 通讯与会话 ===== - { - path: 'sessions', - name: 'ChannelsSessions', - component: () => import('../components/channels/communication/SessionListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'messages', - name: 'ChannelsMessages', - component: () => import('../components/channels/communication/MessageListView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'outbox', - name: 'ChannelsOutbox', - component: () => import('../components/channels/communication/OutboxMonitorView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'directory', - name: 'ChannelsDirectory', - component: () => import('../components/channels/communication/DirectoryQueryView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - // ===== 安全治理 ===== - { - path: 'pairings', - name: 'ChannelsPairings', - component: () => import('../components/channels/security/PairingApprovalView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'allowlists', - name: 'ChannelsAllowlists', - component: () => import('../components/channels/security/AllowlistView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'content-reviews', - name: 'ChannelsContentReviews', - component: () => import('../components/channels/security/ContentReviewView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - // ===== 系统治理 ===== - { - path: 'configs', - name: 'ChannelsConfigs', - component: () => import('../components/channels/system-governance/ConfigManageView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'plugins', - name: 'ChannelsPlugins', - component: () => import('../components/channels/access-ops/PluginManageView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - { - path: 'audit-logs', - name: 'ChannelsAuditLogs', - component: () => import('../components/channels/system-governance/AuditLogView.vue'), - meta: { keepAlive: true, requiresAuth: true, requiresAdmin: true } - }, - // ===== 数据洞察 ===== - { - path: 'analytics', - name: 'ChannelsAnalytics', - component: () => import('../components/channels/analytics/AnalyticsView.vue'), - meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true } - } - ] - } - ] - }, - { - path: '/:pathMatch(.*)*', - name: 'NotFound', - component: () => import('../views/EmptyView.vue'), - meta: { requiresAuth: false } - } - ] + routes }) +// 开发环境校验 channels / external-systems 导航配置与路由表一致性,提前发现同步遗漏 +if (import.meta.env.DEV) { + validateNavigationAgainstRoutes(router) + validateEsNavigationAgainstRoutes(router) +} + // 全局前置守卫 router.beforeEach(async (to) => { // 检查路由是否需要认证 @@ -584,3 +669,4 @@ router.beforeEach(async (to) => { }) export default router +export { routes } diff --git a/web/src/stores/externalSystems.js b/web/src/stores/externalSystems.js index a9bcde20..11585e55 100644 --- a/web/src/stores/externalSystems.js +++ b/web/src/stores/externalSystems.js @@ -1,87 +1,113 @@ /** * 外部系统集成框架 - 状态管理 * - * 管理模块级别的共享状态:当前激活的业务域分组、列表筛选条件缓存等。 + * 管理模块级别的共享状态:侧边栏折叠、分组展开、列表筛选条件缓存等。 + * navCollapsed / expandedGroups 通过 pinia-plugin-persistedstate 持久化到 localStorage, + * 刷新后保留用户习惯。 */ import { defineStore } from 'pinia' import { ref } from 'vue' -export const useExternalSystemsStore = defineStore('externalSystems', () => { - // 当前激活的业务域分组 - const activeGroup = ref('overview') +export const useExternalSystemsStore = defineStore( + 'externalSystems', + () => { + // 侧边栏折叠态(与 L2 侧边栏一致,持久化) + const navCollapsed = ref(false) - // 各分组的展开/折叠状态(记忆用户上次操作) - const expandedGroups = ref({ - 'asset-management': false, - 'runtime-monitoring': false, - 'governance-control': false, - 'integration-config': false - }) + // 当前激活的业务域分组 + const activeGroup = ref('overview') - // 各模块列表筛选条件缓存(key: 模块名, value: 筛选对象) - const filterCache = ref({}) + // 各分组的展开/折叠状态(记忆用户上次操作) + const expandedGroups = ref({ + 'asset-management': false, + 'runtime-monitoring': false, + 'governance-control': false, + 'integration-config': false + }) - // 各模块分页状态缓存(key: 模块名, value: { page, pageSize }) - const paginationCache = ref({}) + // 各模块列表筛选条件缓存(key: 模块名, value: 筛选对象) + const filterCache = ref({}) - // 设置当前业务域分组 - const setActiveGroup = (group) => { - activeGroup.value = group - } + // 各模块分页状态缓存(key: 模块名, value: { page, pageSize }) + const paginationCache = ref({}) - // 切换分组展开/折叠 - const toggleGroup = (key) => { - expandedGroups.value[key] = !expandedGroups.value[key] - } + // 设置当前业务域分组 + const setActiveGroup = (group) => { + activeGroup.value = group + } - // 确保激活分组处于展开状态(路由跳转时自动调用) - const ensureGroupExpanded = (key) => { - if (key && !expandedGroups.value[key]) { - expandedGroups.value[key] = true + // 切换侧边栏折叠/展开 + const toggleNavCollapsed = () => { + navCollapsed.value = !navCollapsed.value + } + + // 判断指定分组是否展开 + const isGroupExpanded = (key) => !!expandedGroups.value[key] + + // 切换分组展开/折叠 + const toggleGroupExpanded = (key) => { + expandedGroups.value[key] = !expandedGroups.value[key] + } + + // 展开指定分组(不折叠已展开的分组) + const expandGroup = (key) => { + if (key && !expandedGroups.value[key]) { + expandedGroups.value[key] = true + } + } + + // 缓存筛选条件 + const setFilterCache = (module, filter) => { + filterCache.value[module] = filter + } + + // 读取筛选条件 + const getFilterCache = (module) => { + return filterCache.value[module] || {} + } + + // 清空筛选条件 + const clearFilterCache = (module) => { + if (module) { + delete filterCache.value[module] + } else { + filterCache.value = {} + } + } + + // 缓存分页状态 + const setPaginationCache = (module, pagination) => { + paginationCache.value[module] = pagination + } + + // 读取分页状态 + const getPaginationCache = (module) => { + return paginationCache.value[module] || { page: 1, pageSize: 20 } + } + + return { + navCollapsed, + activeGroup, + expandedGroups, + filterCache, + paginationCache, + setActiveGroup, + toggleNavCollapsed, + isGroupExpanded, + toggleGroupExpanded, + expandGroup, + setFilterCache, + getFilterCache, + clearFilterCache, + setPaginationCache, + getPaginationCache + } + }, + { + persist: { + key: 'external-systems-store', + storage: localStorage, + pick: ['navCollapsed', 'expandedGroups'] } } - - // 缓存筛选条件 - const setFilterCache = (module, filter) => { - filterCache.value[module] = filter - } - - // 读取筛选条件 - const getFilterCache = (module) => { - return filterCache.value[module] || {} - } - - // 清空筛选条件 - const clearFilterCache = (module) => { - if (module) { - delete filterCache.value[module] - } else { - filterCache.value = {} - } - } - - // 缓存分页状态 - const setPaginationCache = (module, pagination) => { - paginationCache.value[module] = pagination - } - - // 读取分页状态 - const getPaginationCache = (module) => { - return paginationCache.value[module] || { page: 1, pageSize: 20 } - } - - return { - activeGroup, - expandedGroups, - filterCache, - paginationCache, - setActiveGroup, - toggleGroup, - ensureGroupExpanded, - setFilterCache, - getFilterCache, - clearFilterCache, - setPaginationCache, - getPaginationCache - } -}) +) diff --git a/web/src/stores/scheduler.js b/web/src/stores/scheduler.js index 0ea228c5..563309b9 100644 --- a/web/src/stores/scheduler.js +++ b/web/src/stores/scheduler.js @@ -86,6 +86,6 @@ export const useSchedulerStore = defineStore('scheduler', () => { setPaginationCache, getPaginationCache, clearPaginationCache, - $reset, + $reset } })