feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
977783c126
commit
9eb8613c05
126 files changed
+17436
No files matched your search
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
DEFAULT_FAQ_CATEGORY = "General"
|
||||
# Column widths, kept here so the API schemas bound input to exactly what the
|
||||
# columns accept instead of restating the numbers (see models.schemas.faq).
|
||||
FAQ_SLUG_MAX_LENGTH = 80
|
||||
FAQ_META_TITLE_MAX_LENGTH = 120
|
||||
FAQ_META_DESCRIPTION_MAX_LENGTH = 300
|
||||
FAQ_URL_PATH_MAX_LENGTH = 400
|
||||
FAQ_SOURCE_URL_MAX_LENGTH = 2048
|
||||
|
||||
|
||||
class FAQStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PUBLISHED = "published"
|
||||
|
||||
|
||||
class FAQ(Base):
|
||||
"""A single question/answer pair shown on the org's public help center.
|
||||
|
||||
FAQs are a standalone projection of the knowledge base: generation only
|
||||
reads knowledge content, and editing/deleting FAQs never touches the
|
||||
knowledge tables. Drafts stay private until explicitly published.
|
||||
"""
|
||||
__tablename__ = "faqs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
# No single-column index: both composite indexes below lead with org id.
|
||||
organization_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
question = Column(Text, nullable=False)
|
||||
# Answer is authored/stored as Markdown; rendered to sanitized HTML on the
|
||||
# public article page (app.services.help_center_content).
|
||||
answer = Column(Text, nullable=False)
|
||||
# URL slug for the public article page (/a/{slug}). Assigned from the
|
||||
# question at creation and kept stable so published article URLs don't churn
|
||||
# on edits — but editable by hand for SEO (app.api.help_center.faqs).
|
||||
# Unique per org (see ix_faqs_org_slug); NULL until assigned.
|
||||
slug = Column(String(FAQ_SLUG_MAX_LENGTH), nullable=True)
|
||||
# The original URL path of an article migrated in from another help center.
|
||||
# When set, the article is SERVED at this exact path and /a/{slug} 301s to
|
||||
# it, so the org keeps the URLs it already ranks for instead of losing that
|
||||
# SEO to a migration. Root-relative, no trailing slash, and percent-DECODED
|
||||
# because that is the form the ASGI scope hands us to match against (every
|
||||
# emitted URL re-encodes it — see help_center_seo.article_url).
|
||||
# Unique per org (see ix_faqs_org_url_path); NULL means "served at /a/{slug}",
|
||||
# which is every article that wasn't migrated.
|
||||
url_path = Column(String(FAQ_URL_PATH_MAX_LENGTH), nullable=True)
|
||||
# Where an imported article was fetched from, post-redirect. Provenance only
|
||||
# — never routed on — and kept even when url_path had to be dropped, so the
|
||||
# original URL is still recoverable.
|
||||
source_url = Column(String(FAQ_SOURCE_URL_MAX_LENGTH), nullable=True)
|
||||
# Per-article SEO overrides for the public page's <title> and meta
|
||||
# description. NULL means "derive from the content": the question and an
|
||||
# excerpt of the answer (see app.api.help_center_public). Lengths mirror
|
||||
# help_center_settings.title/description.
|
||||
meta_title = Column(String(FAQ_META_TITLE_MAX_LENGTH), nullable=True)
|
||||
meta_description = Column(String(FAQ_META_DESCRIPTION_MAX_LENGTH), nullable=True)
|
||||
# Free-form category assigned by the LLM grouping step (or the user).
|
||||
category = Column(String(100), nullable=False, default=DEFAULT_FAQ_CATEGORY)
|
||||
# Plain string (values from FAQStatus), like knowledge_queue.status — new
|
||||
# states never need an ALTER TYPE migration.
|
||||
status = Column(String, nullable=False, default=FAQStatus.DRAFT)
|
||||
# Provenance: the knowledge source it was generated from (if any) plus a
|
||||
# human-readable label ("example.com/docs", "Imported from support.x.com",
|
||||
# "Added manually") that survives source deletion.
|
||||
knowledge_id = Column(Integer, ForeignKey("knowledge.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
source_label = Column(String(255), nullable=True)
|
||||
generation_job_id = Column(
|
||||
Integer, ForeignKey("faq_generation_jobs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
# "Was this helpful?" tallies (de-duplicated per IP/day at the API).
|
||||
helpful_yes = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
helpful_no = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
organization = relationship("Organization")
|
||||
knowledge = relationship("Knowledge")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_faqs_org_status", "organization_id", "status"),
|
||||
Index("ix_faqs_org_category", "organization_id", "category"),
|
||||
# Serves the public /a/{slug} lookup and enforces per-org uniqueness.
|
||||
# Multiple NULL slugs coexist (Postgres treats NULLs as distinct).
|
||||
Index("ix_faqs_org_slug", "organization_id", "slug", unique=True),
|
||||
# Serves the preserved-path lookup made for any URL no other public
|
||||
# route claimed, and enforces per-org uniqueness. NULLs coexist.
|
||||
Index("ix_faqs_org_url_path", "organization_id", "url_path", unique=True),
|
||||
)
|
||||
Reference in new issue
Block a user