chore: 批量整理代码变更,修复细节与优化结构

- 修复 scheduler store 末尾多余逗号
- 将智能体名称从“语析”改为“Kris”
- 新增获取运行记录UID的权限校验接口
- 重构操作日志写入逻辑,使用独立会话避免事务污染
- 注册外部系统调度处理器
- 优化全局错误处理器,统一响应格式与序列化处理
- 新增调度任务运行日志的handler_name字段与索引
- 重构任务执行函数,新增payload覆盖与操作人审计参数
- 重写外部系统store,新增侧边栏折叠状态与持久化
- 优化会话、消息等模型的索引、约束与字段类型
- 新增多项配置项与环境变量支持
- 重构渠道相关模型,新增索引、约束与字段优化
- 新增渠道插件模型与内容审核模型的完善
This commit is contained in:
Kris 2026-07-11 22:06:21 +08:00
parent 80296b7db1
commit 56022a9199
14 changed files with 1874 additions and 765 deletions

View File

@ -7,7 +7,7 @@ from yuxi.utils.paths import (
)
PROMPT = f"""
你是一个交互式智能体语析
你是一个交互式智能体Kris
专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题
如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业

View File

@ -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:

View File

@ -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))

View File

@ -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}")

View File

@ -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
# 新增业务模块时,在对应模块的 ``<module>/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,
]

View File

@ -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`` 为触发人 uidmanual 时由 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: 触发人 uidmanual 时为管理员 uidauto 时为 ``"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,
)

View File

@ -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")

View File

@ -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-32discovered/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="关联渠道账户 IDORM 外键)",
)
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="统一身份 IDFR-06 跨渠道关联)")
owner_peer_id = Column(String(256), nullable=True, comment="主会话所有者对端 IDFR-26")
unified_identity_id = Column(String(64), nullable=True, comment="统一身份 IDFR-06 跨渠道关联)")
owner_peer_id = Column(String(PEER_ID_MAX_LENGTH), nullable=True, comment="主会话所有者对端 IDFR-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="关联渠道账户 IDORM 外键)",
)
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="绑定 IDUUID业务标识")
# 业务标识unique=True 与同模块其他业务标识一致,防止重复导致 MultipleResultsFound
binding_id = Column(String(64), nullable=False, unique=True, comment="绑定 IDUUID业务标识")
# 关联
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),

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

@ -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``
本处理器统一捕获并转换为项目格式状态码保持 422FastAPI 标准
仅统一响应结构
``exc.errors()`` 返回的 error dict ``ctx`` 字段可能携带非 JSON 可序列化
对象 ``field_validator`` 抛出 ``ValueError`` ctx
``{"error": <ValueError 实例>}``直接放入 ``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 ""

File diff suppressed because it is too large Load Diff

View File

@ -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
}
})
)

View File

@ -86,6 +86,6 @@ export const useSchedulerStore = defineStore('scheduler', () => {
setPaginationCache,
getPaginationCache,
clearPaginationCache,
$reset,
$reset
}
})