第二步
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""SQLAlchemy 2.0 模型包。"""
|
||||
|
||||
from app.models.base import Base, generate_uuid, utcnow_iso
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
from app.models.document_category import DocumentCategory
|
||||
from app.models.document import Document
|
||||
from app.models.access_log import AccessLog
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"generate_uuid",
|
||||
"utcnow_iso",
|
||||
"Plan",
|
||||
"User",
|
||||
"KnowledgeBase",
|
||||
"DocumentCategory",
|
||||
"Document",
|
||||
"AccessLog",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""AccessLog 访问日志模型。"""
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class AccessLog(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""访问日志模型。
|
||||
|
||||
技术审查 §2.2:access_logs 表。
|
||||
"""
|
||||
|
||||
__tablename__ = "access_logs"
|
||||
|
||||
knowledge_base_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("knowledge_bases.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="知识库 ID",
|
||||
)
|
||||
document_id: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("documents.id"),
|
||||
nullable=True,
|
||||
comment="文档 ID (可选)",
|
||||
)
|
||||
path: Mapped[str] = mapped_column(
|
||||
String(1024),
|
||||
nullable=False,
|
||||
comment="请求路径",
|
||||
)
|
||||
accessed_at: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
comment="访问时间 (ISO8601)",
|
||||
)
|
||||
user_agent: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="User-Agent",
|
||||
)
|
||||
request_type: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
nullable=True,
|
||||
comment="请求类型 (html/md/txt/json/search)",
|
||||
)
|
||||
|
||||
# 关系
|
||||
knowledge_base = relationship("KnowledgeBase", back_populates="access_logs", lazy="selectin")
|
||||
document = relationship("Document", back_populates="access_logs", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AccessLog path={self.path!r} at={self.accessed_at!r}>"
|
||||
@@ -0,0 +1,52 @@
|
||||
"""SQLAlchemy 2.0 基础模型类与 Mixin。"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""生成 UUID4 十六进制字符串(32字符)。"""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
"""返回 UTC 当前时间的 ISO8601 字符串。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy 声明式基类。"""
|
||||
pass
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
"""UUID 主键 Mixin。"""
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
primary_key=True,
|
||||
default=generate_uuid,
|
||||
comment="UUID4 十六进制主键",
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""创建/更新时间 Mixin。"""
|
||||
|
||||
created_at: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=utcnow_iso,
|
||||
nullable=False,
|
||||
comment="创建时间 (ISO8601)",
|
||||
)
|
||||
|
||||
updated_at: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=utcnow_iso,
|
||||
onupdate=utcnow_iso,
|
||||
nullable=False,
|
||||
comment="更新时间 (ISO8601)",
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Document 文档模型。"""
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""文档模型。
|
||||
|
||||
技术审查 §2.2:documents 表。
|
||||
"""
|
||||
|
||||
__tablename__ = "documents"
|
||||
|
||||
knowledge_base_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("knowledge_bases.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="所属知识库 ID",
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="所有者用户 ID (冗余,便于隔离校验)",
|
||||
)
|
||||
category_id: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("document_categories.id"),
|
||||
nullable=True,
|
||||
comment="分类 ID",
|
||||
)
|
||||
original_filename: Mapped[str] = mapped_column(
|
||||
String(512),
|
||||
nullable=False,
|
||||
comment="原始文件名 (仅展示)",
|
||||
)
|
||||
storage_path: Mapped[str] = mapped_column(
|
||||
String(1024),
|
||||
nullable=False,
|
||||
comment="相对于 data/ 的物理存储路径",
|
||||
)
|
||||
markdown_path: Mapped[str | None] = mapped_column(
|
||||
String(1024),
|
||||
nullable=True,
|
||||
comment="相对于 data/ 的 Markdown 文件路径",
|
||||
)
|
||||
file_size: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="文件大小 (字节)",
|
||||
)
|
||||
mime_type: Mapped[str] = mapped_column(
|
||||
String(127),
|
||||
nullable=False,
|
||||
comment="MIME 类型",
|
||||
)
|
||||
file_ext: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
comment="文件扩展名 (.docx/.pdf)",
|
||||
)
|
||||
sha256: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="文件 SHA-256 哈希",
|
||||
)
|
||||
doc_token_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
unique=True,
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="SHA-256(document_token) 十六进制",
|
||||
)
|
||||
doc_token_encrypted: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Fernet 加密的 document_token 原文",
|
||||
)
|
||||
doc_token_hint: Mapped[str | None] = mapped_column(
|
||||
String(16),
|
||||
nullable=True,
|
||||
comment="document_token 末 8 位明文",
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(
|
||||
String(512),
|
||||
nullable=True,
|
||||
comment="文档标题 (用户可改)",
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="文档描述 (用户可改)",
|
||||
)
|
||||
keywords: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="关键词 (逗号分隔)",
|
||||
)
|
||||
content_summary: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="抽取式摘要 (~200字)",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
default="PENDING",
|
||||
nullable=False,
|
||||
comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)",
|
||||
)
|
||||
error_code: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
comment="错误码 (如 SCANNED_PDF_NO_TEXT_LAYER)",
|
||||
)
|
||||
|
||||
# 关系
|
||||
knowledge_base = relationship("KnowledgeBase", back_populates="documents", lazy="selectin")
|
||||
user = relationship("User", back_populates="documents", lazy="selectin")
|
||||
category = relationship("DocumentCategory", back_populates="documents", lazy="selectin")
|
||||
access_logs = relationship("AccessLog", back_populates="document", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Document {self.original_filename!r} (status={self.status!r})>"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""DocumentCategory 文档分类模型。"""
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""文档分类模型。
|
||||
|
||||
技术审查 §2.2:document_categories 表。
|
||||
"""
|
||||
|
||||
__tablename__ = "document_categories"
|
||||
|
||||
knowledge_base_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("knowledge_bases.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="所属知识库 ID",
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="分类名称",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="排序序号",
|
||||
)
|
||||
|
||||
# 关系
|
||||
knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin")
|
||||
documents = relationship("Document", back_populates="category", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DocumentCategory {self.name!r} (kb={self.knowledge_base_id!r})>"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""KnowledgeBase 知识库模型。"""
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""知识库模型。
|
||||
|
||||
技术审查 §2.2:knowledge_bases 表。
|
||||
"""
|
||||
|
||||
__tablename__ = "knowledge_bases"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="所有者用户 ID",
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="知识库名称",
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="知识库描述",
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="是否启用",
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="SHA-256(secret_token) 十六进制",
|
||||
)
|
||||
token_encrypted: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="Fernet 加密的 token 原文",
|
||||
)
|
||||
token_hint: Mapped[str | None] = mapped_column(
|
||||
String(16),
|
||||
nullable=True,
|
||||
comment="token 末 8 位明文,供后台识别",
|
||||
)
|
||||
|
||||
# 关系
|
||||
user = relationship("User", back_populates="knowledge_bases", lazy="selectin")
|
||||
documents = relationship("Document", back_populates="knowledge_base", lazy="selectin")
|
||||
categories = relationship("DocumentCategory", back_populates="knowledge_base", lazy="selectin")
|
||||
access_logs = relationship("AccessLog", back_populates="knowledge_base", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<KnowledgeBase {self.name!r} (user={self.user_id!r})>"
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Plan 套餐模型。"""
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class Plan(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""用户套餐(免费/基础/专业等)。
|
||||
|
||||
技术审查 §2.2:plans 表。
|
||||
"""
|
||||
|
||||
__tablename__ = "plans"
|
||||
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment="套餐代码 (free/basic/pro)",
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="套餐名称",
|
||||
)
|
||||
storage_quota: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="存储配额 (字节)",
|
||||
)
|
||||
max_file_size: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="单文件大小上限 (字节)",
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
comment="是否可用",
|
||||
)
|
||||
|
||||
# 关系
|
||||
users = relationship("User", back_populates="plan", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Plan {self.code!r} ({self.name!r})>"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""User 用户模型。"""
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
"""用户模型。
|
||||
|
||||
技术审查 §2.2:users 表。
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="用户名",
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="邮箱",
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
comment="Argon2id 密码哈希",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
default="active",
|
||||
nullable=False,
|
||||
comment="状态 (active/disabled)",
|
||||
)
|
||||
plan_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("plans.id"),
|
||||
nullable=False,
|
||||
comment="套餐 ID",
|
||||
)
|
||||
storage_used: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="已用存储 (字节)",
|
||||
)
|
||||
|
||||
# 关系
|
||||
plan = relationship("Plan", back_populates="users", lazy="selectin")
|
||||
knowledge_bases = relationship("KnowledgeBase", back_populates="user", lazy="selectin")
|
||||
documents = relationship("Document", back_populates="user", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username!r}>"
|
||||
Reference in New Issue
Block a user