chore: 批量整理代码变更,修复细节与优化结构
- 修复 scheduler store 末尾多余逗号 - 将智能体名称从“语析”改为“Kris” - 新增获取运行记录UID的权限校验接口 - 重构操作日志写入逻辑,使用独立会话避免事务污染 - 注册外部系统调度处理器 - 优化全局错误处理器,统一响应格式与序列化处理 - 新增调度任务运行日志的handler_name字段与索引 - 重构任务执行函数,新增payload覆盖与操作人审计参数 - 重写外部系统store,新增侧边栏折叠状态与持久化 - 优化会话、消息等模型的索引、约束与字段类型 - 新增多项配置项与环境变量支持 - 重构渠道相关模型,新增索引、约束与字段优化 - 新增渠道插件模型与内容审核模型的完善
This commit is contained in:
parent
80296b7db1
commit
56022a9199
@ -7,7 +7,7 @@ from yuxi.utils.paths import (
|
||||
)
|
||||
|
||||
PROMPT = f"""
|
||||
你是一个交互式智能体“语析“。
|
||||
你是一个交互式智能体“Kris“。
|
||||
|
||||
专门用来回答用户的问题。请根据用户提供的信息,尽可能详细地回答问题。
|
||||
如果你不确定答案,可以说你不知道,但请尽量提供相关的信息或建议。请保持礼貌和专业。
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
|
||||
@ -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": <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
@ -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
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@ -86,6 +86,6 @@ export const useSchedulerStore = defineStore('scheduler', () => {
|
||||
setPaginationCache,
|
||||
getPaginationCache,
|
||||
clearPaginationCache,
|
||||
$reset,
|
||||
$reset
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user