feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..config import get_settings
|
||||
from ..deps import CurrentUser, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser, Member, Workspace
|
||||
from ..schemas import Envelope, ok
|
||||
from ..security import create_token_pair, decode_token
|
||||
from ..stores import get_store, sms_code_key, sms_throttle_key
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
settings = get_settings()
|
||||
|
||||
PHONE_PATTERN = r"^1[3-9]\d{9}$"
|
||||
|
||||
|
||||
def mask_phone(phone: str) -> str:
|
||||
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
|
||||
|
||||
|
||||
class SendCodeIn(BaseModel):
|
||||
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
phone: str = Field(pattern=PHONE_PATTERN)
|
||||
code: str = Field(min_length=4, max_length=8)
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str = Field(min_length=10)
|
||||
|
||||
|
||||
@router.post("/sms/send", summary="发送登录验证码")
|
||||
async def send_code(body: SendCodeIn) -> dict:
|
||||
store = get_store()
|
||||
hits = await store.incr(sms_throttle_key(body.phone), 60)
|
||||
if hits > 1:
|
||||
raise AppError(ErrorCode.SMS_TOO_FREQUENT, "发送过于频繁,请 60 秒后重试")
|
||||
|
||||
if settings.sms_provider == "dev":
|
||||
code = settings.sms_dev_code
|
||||
else:
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
# TODO(M0 遗留):接入阿里云/腾讯云短信;当前直接在响应里不回显验证码
|
||||
raise AppError(ErrorCode.INTERNAL, "短信通道尚未接入", http_status=501)
|
||||
|
||||
await store.set(sms_code_key(body.phone), code, settings.sms_code_ttl_seconds)
|
||||
return ok({"sent": True, "expires_in": settings.sms_code_ttl_seconds, "dev_code": code})
|
||||
|
||||
|
||||
@router.post("/login", summary="验证码登录(不存在则自动注册)")
|
||||
async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
store = get_store()
|
||||
stored = await store.get(sms_code_key(body.phone))
|
||||
if stored is None:
|
||||
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码已过期,请重新获取")
|
||||
if not hmac.compare_digest(stored, body.code):
|
||||
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码错误")
|
||||
await store.delete(sms_code_key(body.phone))
|
||||
|
||||
user = (
|
||||
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = AppUser(phone=body.phone, nickname=f"用户{body.phone[-4:]}")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
"created_at": user.created_at,
|
||||
},
|
||||
"tokens": {
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新访问令牌")
|
||||
async def refresh(body: RefreshIn) -> dict:
|
||||
user_id = decode_token(body.refresh_token, "refresh")
|
||||
tokens = create_token_pair(user_id)
|
||||
return ok(
|
||||
{
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", summary="当前用户与所属工作区")
|
||||
async def me(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(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
},
|
||||
"workspaces": [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"slug": ws.slug,
|
||||
"plan": ws.plan,
|
||||
"role": role,
|
||||
"created_at": ws.created_at,
|
||||
}
|
||||
for ws, role in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""对外输出模型。字段命名一次定死,客户端按契约生成。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
phone: str = Field(description="已打码的手机号,如 138****8000")
|
||||
nickname: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
user: UserOut
|
||||
tokens: TokenOut
|
||||
|
||||
|
||||
class WorkspaceOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
slug: str
|
||||
plan: str
|
||||
role: str = ""
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
role: str
|
||||
status: str
|
||||
nickname: str = ""
|
||||
phone: str = ""
|
||||
joined_at: datetime
|
||||
|
||||
|
||||
class InvitationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
code: str
|
||||
role: str
|
||||
expires_at: datetime
|
||||
accepted_at: datetime | None = None
|
||||
|
||||
|
||||
class MeOut(BaseModel):
|
||||
user: UserOut
|
||||
workspaces: list[WorkspaceOut]
|
||||
@@ -0,0 +1,240 @@
|
||||
"""工作区 / 成员 / 邀请。M0 的验收核心:两个租户互相看不见。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
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 Page, 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)
|
||||
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:]}",
|
||||
"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(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(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(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)
|
||||
Reference in New Issue
Block a user