写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。 这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**—— 能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。 后端 - attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定, 换对象存储只改 services/storage.py 一个文件 - 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头 - 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求 - raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员, 非成员一律 8001 不区分「不存在」与「没权限」 - 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权 前端 - 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单 - RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用 - AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob - 任务备注、工单说明与验收结论、知识正文、评论全部接入 顺手修的 - TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的) 71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB; e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
153 lines
6.3 KiB
Python
153 lines
6.3 KiB
Python
"""数据模型(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_attachment import ATTACHMENT_TENANT_TABLES # noqa: E402
|
||
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,
|
||
*ATTACHMENT_TENANT_TABLES,
|
||
)
|