ForcePilot/src/storage/postgres/models_business.py

590 lines
24 KiB
Python
Raw Normal View History

"""PostgreSQL 业务数据模型 - 用户、部门、对话等相关表"""
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from src.utils.datetime_utils import format_utc_datetime, utc_now_naive
Base = declarative_base()
class Department(Base):
"""部门模型"""
__tablename__ = "departments"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False, unique=True, index=True)
description = Column(String(255), nullable=True)
created_at = Column(DateTime, default=utc_now_naive)
# 关联关系
users = relationship("User", back_populates="department", cascade="all, delete-orphan")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"created_at": format_utc_datetime(self.created_at),
}
class User(Base):
"""用户模型"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String, nullable=False, unique=True, index=True) # 显示名称
user_id = Column(String, nullable=False, unique=True, index=True) # 登录ID
phone_number = Column(String, nullable=True, unique=True, index=True) # 手机号
avatar = Column(String, nullable=True) # 头像URL
password_hash = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # 角色: superadmin, admin, user
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True) # 部门ID
created_at = Column(DateTime, default=utc_now_naive)
last_login = Column(DateTime, nullable=True)
# 登录失败限制相关字段
login_failed_count = Column(Integer, nullable=False, default=0) # 登录失败次数
last_failed_login = Column(DateTime, nullable=True) # 最后一次登录失败时间
login_locked_until = Column(DateTime, nullable=True) # 锁定到什么时候
# 软删除相关字段
is_deleted = Column(Integer, nullable=False, default=0, index=True) # 是否已删除0=否1=是
deleted_at = Column(DateTime, nullable=True) # 删除时间
# 关联操作日志
operation_logs = relationship("OperationLog", back_populates="user", cascade="all, delete-orphan")
# 关联部门
department = relationship("Department", back_populates="users")
def to_dict(self, include_password: bool = False) -> dict[str, Any]:
result = {
"id": self.id,
"username": self.username,
"user_id": self.user_id,
"phone_number": self.phone_number,
"avatar": self.avatar,
"role": self.role,
"department_id": self.department_id,
"created_at": format_utc_datetime(self.created_at),
"last_login": format_utc_datetime(self.last_login),
"login_failed_count": self.login_failed_count,
"last_failed_login": format_utc_datetime(self.last_failed_login),
"login_locked_until": format_utc_datetime(self.login_locked_until),
"is_deleted": self.is_deleted,
"deleted_at": format_utc_datetime(self.deleted_at),
}
if include_password:
result["password_hash"] = self.password_hash
return result
def is_login_locked(self) -> bool:
"""检查用户是否处于登录锁定状态"""
if self.login_locked_until is None:
return False
return utc_now_naive() < self.login_locked_until
def get_remaining_lock_time(self) -> int:
"""获取剩余锁定时间(秒)"""
if self.login_locked_until is None:
return 0
remaining = int((self.login_locked_until - utc_now_naive()).total_seconds())
return max(0, remaining)
def reset_failed_login(self):
"""重置登录失败相关字段"""
self.login_failed_count = 0
self.last_failed_login = None
self.login_locked_until = None
class AgentConfig(Base):
"""智能体配置(按部门共享,多份可切换)"""
__tablename__ = "agent_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
department_id = Column(Integer, ForeignKey("departments.id"), nullable=False, index=True)
agent_id = Column(String(64), nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(String(255), nullable=True)
icon = Column(String(255), nullable=True)
pics = Column(JSON, nullable=False, default=list)
examples = Column(JSON, nullable=False, default=list)
config_json = Column(JSON, nullable=False, default=dict)
is_default = Column(Boolean, nullable=False, default=False, index=True)
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, default=utc_now_naive)
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
__table_args__ = (
UniqueConstraint("department_id", "agent_id", "name", name="uq_agent_configs_department_agent_name"),
Index(
"uq_agent_configs_department_agent_default",
"department_id",
"agent_id",
unique=True,
postgresql_where=is_default.is_(True),
),
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"department_id": self.department_id,
"agent_id": self.agent_id,
"name": self.name,
"description": self.description,
"icon": self.icon,
"pics": self.pics or [],
"examples": self.examples or [],
"config_json": self.config_json or {},
"is_default": bool(self.is_default),
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class Skill(Base):
"""Skill 元数据模型(内容存文件系统,索引存数据库)"""
__tablename__ = "skills"
id = Column(Integer, primary_key=True, autoincrement=True)
slug = Column(String(128), nullable=False, unique=True, index=True, comment="技能唯一标识(目录名)")
name = Column(String(128), nullable=False, comment="技能名称(来自 SKILL.md frontmatter.name")
description = Column(Text, nullable=False, comment="技能描述(来自 SKILL.md frontmatter.description")
tool_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的内置工具名列表")
mcp_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的 MCP 服务名列表")
skill_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的其他 skill slug 列表")
dir_path = Column(String(512), nullable=False, comment="技能目录路径(相对 save_dir")
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, default=utc_now_naive)
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"slug": self.slug,
"name": self.name,
"description": self.description,
"tool_dependencies": self.tool_dependencies or [],
"mcp_dependencies": self.mcp_dependencies or [],
"skill_dependencies": self.skill_dependencies or [],
"dir_path": self.dir_path,
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class Conversation(Base):
"""Conversation table - 对话表"""
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
thread_id = Column(String(64), unique=True, index=True, nullable=False, comment="Thread ID (UUID)")
user_id = Column(String(64), index=True, nullable=False, comment="User ID")
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")
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")
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata")
# Relationships
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
stats = relationship(
"ConversationStats", back_populates="conversation", uselist=False, cascade="all, delete-orphan"
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"thread_id": self.thread_id,
"user_id": self.user_id,
"agent_id": self.agent_id,
"title": self.title,
"status": self.status,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"metadata": self.extra_metadata or {},
}
class Message(Base):
"""Message table - 消息表"""
__tablename__ = "messages"
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"
)
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)")
image_content = Column(Text, nullable=True, comment="Base64 encoded image content for multimodal messages")
# 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")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"conversation_id": self.conversation_id,
"role": self.role,
"content": self.content,
"message_type": self.message_type,
"created_at": format_utc_datetime(self.created_at),
"token_count": self.token_count,
"metadata": self.extra_metadata or {},
"image_content": self.image_content,
"tool_calls": [tc.to_dict() for tc in self.tool_calls] if self.tool_calls else [],
}
def to_simple_dict(self) -> dict[str, Any]:
return {
"role": self.role,
"content": self.content,
}
class ToolCall(Base):
"""ToolCall table - 工具调用表"""
__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")
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")
tool_output = Column(Text, nullable=True, comment="Tool execution result")
status = Column(String(20), default="pending", comment="Status: pending/success/error")
error_message = Column(Text, nullable=True, comment="Error message if failed")
created_at = Column(DateTime, default=utc_now_naive, comment="Creation time")
# Relationships
message = relationship("Message", back_populates="tool_calls")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"message_id": self.message_id,
"langgraph_tool_call_id": self.langgraph_tool_call_id,
"tool_name": self.tool_name,
"tool_input": self.tool_input or {},
"tool_output": self.tool_output,
"status": self.status,
"error_message": self.error_message,
"created_at": format_utc_datetime(self.created_at),
}
class ConversationStats(Base):
"""ConversationStats table - 对话统计表"""
__tablename__ = "conversation_stats"
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
conversation_id = Column(
Integer, ForeignKey("conversations.id"), unique=True, nullable=False, comment="Conversation ID"
)
message_count = Column(Integer, default=0, comment="Total message count")
total_tokens = Column(Integer, default=0, comment="Total tokens used")
model_used = Column(String(100), nullable=True, comment="Model used")
user_feedback = Column(JSON, nullable=True, comment="User feedback")
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")
# Relationships
conversation = relationship("Conversation", back_populates="stats")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"conversation_id": self.conversation_id,
"message_count": self.message_count,
"total_tokens": self.total_tokens,
"model_used": self.model_used,
"user_feedback": self.user_feedback or {},
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}
class OperationLog(Base):
"""操作日志模型"""
__tablename__ = "operation_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
operation = Column(String, nullable=False)
details = Column(Text, nullable=True)
ip_address = Column(String, nullable=True)
timestamp = Column(DateTime, default=utc_now_naive)
# 关联用户
user = relationship("User", back_populates="operation_logs")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"user_id": self.user_id,
"operation": self.operation,
"details": self.details,
"ip_address": self.ip_address,
"timestamp": format_utc_datetime(self.timestamp),
}
class MessageFeedback(Base):
"""Message feedback table - 消息反馈表"""
__tablename__ = "message_feedbacks"
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"
)
user_id = Column(String(64), nullable=False, index=True, comment="User ID who provided feedback")
rating = Column(String(10), nullable=False, comment="Feedback rating: like or dislike")
reason = Column(Text, nullable=True, comment="Optional reason for dislike feedback")
created_at = Column(DateTime, default=utc_now_naive, comment="Feedback creation time")
# Relationships
message = relationship("Message", back_populates="feedbacks")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"message_id": self.message_id,
"user_id": self.user_id,
"rating": self.rating,
"reason": self.reason,
"created_at": format_utc_datetime(self.created_at),
}
class MCPServer(Base):
"""MCP 服务器配置模型"""
__tablename__ = "mcp_servers"
# 核心字段 - name 作为主键
name = Column(String(100), primary_key=True, comment="服务器名称(唯一标识)")
description = Column(String(500), nullable=True, comment="描述")
# 连接配置
transport = Column(String(20), nullable=False, comment="传输类型sse/streamable_http/stdio")
url = Column(String(500), nullable=True, comment="服务器 URLsse/streamable_http")
command = Column(String(500), nullable=True, comment="命令stdio")
args = Column(JSON, nullable=True, comment="命令参数数组stdio")
env = Column(JSON, nullable=True, comment="环境变量stdio")
headers = Column(JSON, nullable=True, comment="HTTP 请求头")
timeout = Column(Integer, nullable=True, comment="HTTP 超时时间(秒)")
sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)")
# UI 增强字段
tags = Column(JSON, nullable=True, comment="标签数组")
icon = Column(String(50), nullable=True, comment="图标emoji")
# 状态字段
enabled = Column(Integer, nullable=False, default=1, comment="是否启用1=是0=否")
disabled_tools = Column(JSON, nullable=True, comment="禁用的工具名称列表")
# 用户追踪
created_by = Column(String(100), nullable=False, comment="创建人用户名")
updated_by = Column(String(100), nullable=False, comment="修改人用户名")
# 时间戳
created_at = Column(DateTime, default=utc_now_naive, comment="创建时间")
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive, comment="更新时间")
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"transport": self.transport,
"url": self.url,
"command": self.command,
"args": self.args or [],
"env": self.env or {},
"headers": self.headers or {},
"timeout": self.timeout,
"sse_read_timeout": self.sse_read_timeout,
"tags": self.tags or [],
"icon": self.icon,
"enabled": bool(self.enabled),
"disabled_tools": self.disabled_tools or [],
"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),
}
def to_mcp_config(self) -> dict[str, Any]:
"""转换为 MCP 配置格式(用于加载到 MCP_SERVERS 缓存)"""
2026-01-24 00:12:30 +08:00
import json
config = {"transport": self.transport}
if self.url:
config["url"] = self.url
if self.command:
config["command"] = self.command
2026-01-24 00:12:30 +08:00
# args 只用于 stdio 传输类型,必须是列表
if self.transport == "stdio" and self.args:
if isinstance(self.args, list):
config["args"] = self.args
elif isinstance(self.args, str):
try:
config["args"] = json.loads(self.args)
except json.JSONDecodeError:
pass
if self.transport == "stdio" and self.env:
if isinstance(self.env, dict):
config["env"] = self.env
elif isinstance(self.env, str):
try:
config["env"] = json.loads(self.env)
except json.JSONDecodeError:
pass
2026-01-24 00:12:30 +08:00
# headers 只用于 sse/streamable_http 传输类型
if self.transport in ("sse", "streamable_http") and self.headers:
if isinstance(self.headers, dict):
config["headers"] = self.headers
elif isinstance(self.headers, str):
try:
config["headers"] = json.loads(self.headers)
except json.JSONDecodeError:
pass
if self.timeout is not None:
config["timeout"] = self.timeout
if self.sse_read_timeout is not None:
config["sse_read_timeout"] = self.sse_read_timeout
if self.disabled_tools:
config["disabled_tools"] = self.disabled_tools
return config
class TaskRecord(Base):
__tablename__ = "tasks"
id = Column(String(32), primary_key=True)
name = Column(String(255), nullable=False)
type = Column(String(64), nullable=False, index=True)
status = Column(String(32), nullable=False, default="pending", index=True)
progress = Column(Float, nullable=False, default=0.0)
message = Column(Text, nullable=False, default="")
payload = Column(JSON, nullable=True)
result = Column(JSON, nullable=True)
error = Column(Text, nullable=True)
cancel_requested = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=utc_now_naive, index=True)
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"type": self.type,
"status": self.status,
"progress": self.progress,
"message": self.message,
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
"started_at": format_utc_datetime(self.started_at),
"completed_at": format_utc_datetime(self.completed_at),
"payload": self.payload or {},
"result": self.result,
"error": self.error,
"cancel_requested": bool(self.cancel_requested),
}
def to_summary_dict(self) -> dict[str, Any]:
data = self.to_dict()
data.pop("payload", None)
data.pop("result", None)
return data
class AgentRun(Base):
"""AgentRun table - 运行任务表"""
__tablename__ = "agent_runs"
id = Column(String(64), primary_key=True, comment="Run ID (UUID)")
thread_id = Column(String(64), index=True, nullable=False, comment="Thread ID")
agent_id = Column(String(64), index=True, nullable=False, comment="Agent ID")
user_id = Column(String(64), index=True, nullable=False, comment="User ID")
status = Column(
String(32),
index=True,
nullable=False,
default="pending",
comment="Run status: pending/running/completed/failed/cancel_requested/cancelled/interrupted",
)
request_id = Column(String(64), unique=True, index=True, nullable=False, comment="Idempotency request ID")
input_payload = Column(JSON, nullable=False, default=dict, comment="Original input payload")
error_type = Column(String(64), nullable=True, comment="Error type")
error_message = Column(Text, nullable=True, comment="Error message")
started_at = Column(DateTime, nullable=True, comment="Start time")
finished_at = Column(DateTime, nullable=True, comment="Finish time")
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")
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"thread_id": self.thread_id,
"agent_id": self.agent_id,
"user_id": self.user_id,
"status": self.status,
"request_id": self.request_id,
"input_payload": self.input_payload or {},
"error_type": self.error_type,
"error_message": self.error_message,
"started_at": format_utc_datetime(self.started_at),
"finished_at": format_utc_datetime(self.finished_at),
"created_at": format_utc_datetime(self.created_at),
"updated_at": format_utc_datetime(self.updated_at),
}