第二步

This commit is contained in:
amb
2026-09-01 12:24:43 +08:00
parent 47bf6cc5ca
commit 1d8621717a
14 changed files with 815 additions and 9 deletions
+21
View File
@@ -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",
]
+56
View File
@@ -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.2access_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}>"
+52
View File
@@ -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)",
)
+128
View File
@@ -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.2documents 表。
"""
__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})>"
+41
View File
@@ -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.2document_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})>"
+65
View File
@@ -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.2knowledge_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})>"
+49
View File
@@ -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})>"
+61
View File
@@ -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}>"