Files
DadaoTask/backend/app/models.py
T
dadao e9addeed25 feat(knowledge): 工单验收自动生成草稿,核对后发布,来源可追
- knowledge_doc 与 item 1:1,除 source_kind=manual 外必须有来源(库级约束)
- 状态机 draft/in_review/published/archived,发布要 owner/admin;非法动作回 allowed_from 与 allowed_actions
- 工单验收通过自动落 postmortem 草稿(generated_by=rule),同一来源只生成一次
- 接口:列表/详情/手记/改内容/状态动作/从事项沉淀;已发布禁改,改内容带乐观锁
- Web 知识页 + 详情抽屉;任务与工单详情里可「沉淀成知识」
- 8 个后端用例 + e2e 7 项断言(沉淀 → 待核对 → 发布)
2026-10-28 14:02:35 +08:00

151 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""数据模型(M0 范围:workspace / user / member / invitation / audit_log)。
M1 之后的任务、工单、知识表另起模块,但**继承同样的约定**:
- 主键 uuid,应用侧生成(不依赖数据库扩展,方便迁移与分库)
- 通用列 created_at / updated_at / version / deleted_at
- 所有租户表必须有 workspace_id NOT NULL(AGENTS.md 第 2.5 条)
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
def new_uuid() -> uuid.UUID:
return uuid.uuid4()
class Timestamps:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
class AppUser(Base, Timestamps):
"""全局用户,**不挂租户**(一个人可以属于多个工作区)。"""
__tablename__ = "app_user"
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
class Workspace(Base, Timestamps):
"""租户根。RLS 策略:workspace_id(id) = current_setting('app.ws_id')。"""
__tablename__ = "workspace"
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
name: Mapped[str] = mapped_column(String(64), nullable=False)
slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
plan: Mapped[str] = mapped_column(String(16), nullable=False, default="free")
owner_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Member(Base, Timestamps):
"""成员关系。RLS 策略:本租户可见 OR 自己是本人(用于"我加入了哪些工作区")。"""
__tablename__ = "member"
__table_args__ = (
UniqueConstraint("workspace_id", "user_id", name="uq_member_ws_user"),
Index("ix_member_user", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Invitation(Base, Timestamps):
__tablename__ = "invitation"
__table_args__ = (
UniqueConstraint("code", name="uq_invitation_code"),
Index("ix_invitation_ws", "workspace_id"),
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
code: Mapped[str] = mapped_column(String(32), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
invited_by: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
accepted_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
class AuditLog(Base):
"""审计日志。**只增不改**;租户内可见,平台侧另有运维通道(M7)。"""
__tablename__ = "audit_log"
__table_args__ = (Index("ix_audit_ws_created", "workspace_id", "created_at"),)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="SET NULL")
)
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
action: Mapped[str] = mapped_column(String(64), nullable=False)
target_type: Mapped[str] = mapped_column(String(32), nullable=False, default="")
target_id: Mapped[str] = mapped_column(String(64), nullable=False, default="")
payload: Mapped[dict | None] = mapped_column(JSONB)
ip: Mapped[str] = mapped_column(String(64), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# 需要 RLS 的租户表(迁移与测试共用这一份清单,避免漏加)
#
# 任务域的表在 models_task.py、工单域在 models_ticket.py —— 它们 import 本模块的 Base,
# 所以只能在文件末尾反向 import,否则循环导入。
from .models_knowledge import KNOWLEDGE_TENANT_TABLES # noqa: E402
from .models_task import TASK_TENANT_TABLES # noqa: E402
from .models_ticket import TICKET_TENANT_TABLES # noqa: E402
TENANT_TABLES: tuple[str, ...] = (
"workspace",
"member",
"invitation",
"audit_log",
*TASK_TENANT_TABLES,
*TICKET_TENANT_TABLES,
*KNOWLEDGE_TENANT_TABLES,
)