Files
DadaoTask/backend/app/models.py
T
Steven b5170ad1ce
CI / backend (push) Canceled after 0s
feat(auth): 邮箱验证码注册,生产重新有了自助入口
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  POST /auth/email/code 只做三件事:查重、限流、发码。
  填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。

邮箱既是账号名也是唯一联系方式,所以必须真验一次码

  验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
  "随手填一个"会让账号变成没人能找回的孤岛。

app_user.phone 改为可空(迁移 0011)

  邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
  成员列表同理,不再伪装成一串星号。

防刷

  同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
  6 位数可以被刮,不设上限等于把注册口子交给脚本。

本机联调开关 EMAIL_CODE_DEV_ECHO

  为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
  生产打开会让 validate_runtime() 拒绝启动。

前端

  新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
  服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
  决定要不要显示「还没有账号?用邮箱注册」。

验证

  pytest 139 passed、ruff check / format 全绿
  tsc --noEmit 0、vite build 通过
  e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
    → 同一邮箱再发码回 2019 → 控制台零报错
  e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
  e2e account.mjs 14 项、knowledge.mjs 全绿

顺带修的两处测试

  test_account 的字段白名单补 email_verified(接口确实多了这个字段);
  test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
  EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
2026-11-03 01:04:26 +08:00

179 lines
7.9 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 | None] = mapped_column(String(32), 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")
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
password_hash: Mapped[str | None] = mapped_column(String(255))
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
email: Mapped[str | None] = mapped_column(String(255))
# 邮箱验过的时间:注册时发码验证过就有值;账号页手工绑邮箱时也走同一个验证流程。
# 用"有没有值"区分"填了个字符串"和"这个地址真是他的"。
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class SystemSetting(Base, Timestamps):
"""平台级键值设置(SMTP 等),**不挂租户、没有 RLS**。
刻意不做成租户表:发信账号是整台服务共用的,某个工作区的 owner 不该看到
平台的 SMTP 授权码。写权限由 API 层的「平台管理员」依赖把关(见 deps.py)。
"""
__tablename__ = "system_setting"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
)
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,
)