Files
DadaoTask/backend/app/api/workspaces.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

239 lines
8.0 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 的验收核心:两个租户互相看不见。"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import select, tuple_
from ..audit import record as audit
from ..db import bind_tenant
from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep
from ..errors import AppError, ErrorCode
from ..models import AppUser, Invitation, Member, Workspace
from ..schemas import decode_cursor, encode_cursor, ok
from ..security import invite_code_matches, new_invite_code
router = APIRouter(prefix="/workspaces", tags=["工作区"])
ROLE_CHOICES = ("admin", "member", "guest")
class CreateWorkspaceIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
slug: str | None = Field(default=None, max_length=64)
class CreateInvitationIn(BaseModel):
role: str = Field(default="member")
expires_hours: int = Field(default=72, ge=1, le=24 * 30)
class JoinIn(BaseModel):
code: str = Field(min_length=4, max_length=32)
def _ws_out(ws: Workspace, role: str) -> dict:
return {
"id": str(ws.id),
"name": ws.name,
"slug": ws.slug,
"plan": ws.plan,
"role": role,
"created_at": ws.created_at,
}
@router.post("", summary="创建工作区(创建者成为 owner)")
async def create_workspace(
body: CreateWorkspaceIn, user: CurrentUser, session: SessionDep, request: Request
) -> dict:
ws_id = uuid.uuid4()
slug = body.slug or f"ws-{ws_id.hex[:8]}"
exists = (
await session.execute(select(Workspace.id).where(Workspace.slug == slug))
).scalar_one_or_none()
if exists is not None:
raise AppError(ErrorCode.CONFLICT, "该 slug 已被占用")
# 先绑租户再插数据:workspace 的 RLS 策略是 id = app.ws_id,
# 插入时 Postgres 用 WITH CHECK 校验,不绑就会直接被拒。
await bind_tenant(session, ws_id, user.id)
ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free")
session.add(ws)
# 先刷 workspace 再插 member:ORM 的 flush 顺序只按 relationship 排序,
# 没有 relationship 的裸外键不会被考虑,member 可能先于 workspace 落地而撞 FK。
await session.flush()
session.add(Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active"))
await session.flush()
await audit(
session,
workspace_id=ws_id,
actor_user_id=user.id,
action="workspace.create",
target_type="workspace",
target_id=str(ws_id),
payload={"name": body.name},
ip=request.client.host if request.client else "",
)
return ok(_ws_out(ws, "owner"))
@router.get("", summary="我的工作区列表")
async def list_workspaces(user: CurrentUser, session: SessionDep) -> dict:
rows = (
await session.execute(
select(Workspace, Member.role)
.join(Member, Member.workspace_id == Workspace.id)
.where(Member.user_id == user.id, Member.status == "active")
.order_by(Workspace.created_at.desc())
)
).all()
return ok({"items": [_ws_out(ws, role) for ws, role in rows]})
@router.get("/{ws_id}", summary="工作区详情")
async def get_workspace(ctx: MemberDep, session: SessionDep) -> dict:
ws = await session.get(Workspace, ctx.ws_id)
if ws is None:
raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "工作区不存在")
return ok(_ws_out(ws, ctx.role))
@router.get("/{ws_id}/members", summary="成员列表(游标分页)")
async def list_members(
ctx: MemberDep,
session: SessionDep,
limit: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None),
) -> dict:
stmt = (
select(Member, AppUser)
.join(AppUser, AppUser.id == Member.user_id)
.where(Member.workspace_id == ctx.ws_id)
)
if cursor:
cur = decode_cursor(cursor)
stmt = stmt.where(tuple_(Member.created_at, Member.id) < (cur.created_at, cur.id))
stmt = stmt.order_by(Member.created_at.desc(), Member.id.desc()).limit(limit + 1)
rows = (await session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
items = [
{
"id": str(m.id),
"user_id": str(m.user_id),
"role": m.role,
"status": m.status,
"nickname": u.nickname,
# 邮箱注册的账号没有手机号:留空比显示一串星号诚实
"phone": f"{u.phone[:3]}****{u.phone[-4:]}" if u.phone else "",
"joined_at": m.joined_at,
}
for m, u in rows
]
next_cursor = (
encode_cursor(rows[-1][0].created_at, rows[-1][0].id) if has_more and rows else None
)
return ok({"items": items, "next_cursor": next_cursor, "has_more": has_more})
@router.post("/{ws_id}/invitations", summary="生成邀请码(admin 及以上)")
async def create_invitation(
body: CreateInvitationIn, ctx: AdminDep, session: SessionDep, request: Request
) -> dict:
if body.role not in ROLE_CHOICES:
raise AppError(ErrorCode.BAD_REQUEST, f"角色只能是 {', '.join(ROLE_CHOICES)}")
if body.role == "admin" and ctx.role != "owner":
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有 owner 能邀请 admin")
invitation = Invitation(
workspace_id=ctx.ws_id,
code=new_invite_code(),
role=body.role,
invited_by=ctx.user.id,
expires_at=datetime.now(timezone.utc) + timedelta(hours=body.expires_hours),
)
session.add(invitation)
await session.flush()
await audit(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="invitation.create",
target_type="invitation",
target_id=str(invitation.id),
payload={"role": body.role},
ip=request.client.host if request.client else "",
)
return ok(
{
"id": str(invitation.id),
"code": invitation.code,
"role": invitation.role,
"expires_at": invitation.expires_at,
}
)
@router.post("/{ws_id}/join", summary="凭邀请码加入工作区")
async def join_workspace(
body: JoinIn, ctx: TenantDep, session: SessionDep, request: Request
) -> dict:
invitation = (
await session.execute(
select(Invitation).where(
Invitation.workspace_id == ctx.ws_id,
Invitation.accepted_at.is_(None),
)
)
).scalars()
matched = next((inv for inv in invitation if invite_code_matches(body.code, inv.code)), None)
if matched is None:
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用")
if matched.expires_at < datetime.now(timezone.utc):
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期")
existing = (
await session.execute(
select(Member).where(Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id)
)
).scalar_one_or_none()
if existing is not None and existing.status == "active":
raise AppError(ErrorCode.ALREADY_A_MEMBER, "你已经是该工作区成员")
if existing is None:
session.add(
Member(
workspace_id=ctx.ws_id,
user_id=ctx.user.id,
role=matched.role,
status="active",
)
)
else:
existing.role = matched.role
existing.status = "active"
matched.accepted_at = datetime.now(timezone.utc)
matched.accepted_by = ctx.user.id
await session.flush()
await audit(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="member.join",
target_type="member",
target_id=str(ctx.user.id),
payload={"role": matched.role},
ip=request.client.host if request.client else "",
)
ws = await session.get(Workspace, ctx.ws_id)
return ok(_ws_out(ws, matched.role) if ws else None)