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,114 @@
|
||||
"""请求依赖:认证、租户上下文、角色校验。
|
||||
|
||||
关键点:**租户上下文必须在任何业务查询之前绑定**,否则 RLS 会返回空集,
|
||||
表现为"数据凭空消失"而不是报错——这类 bug 最难查,所以封装成依赖强制调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .db import bind_tenant, bind_user, get_session
|
||||
from .errors import AppError, ErrorCode
|
||||
from .logging_setup import user_id_ctx, workspace_id_ctx
|
||||
from .models import AppUser, Member
|
||||
from .security import decode_token
|
||||
|
||||
ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthUser:
|
||||
id: uuid.UUID
|
||||
phone: str
|
||||
nickname: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantCtx:
|
||||
ws_id: uuid.UUID
|
||||
user: AuthUser
|
||||
role: str
|
||||
|
||||
def require_role(self, minimum: str) -> None:
|
||||
if ROLE_RANK.get(self.role, 0) < ROLE_RANK[minimum]:
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, f"需要 {minimum} 及以上权限")
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
async def get_bearer_token(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问令牌")
|
||||
return authorization.split(" ", 1)[1].strip()
|
||||
|
||||
|
||||
TokenDep = Annotated[str, Depends(get_bearer_token)]
|
||||
|
||||
|
||||
async def current_user(token: TokenDep, session: SessionDep) -> AuthUser:
|
||||
user_id = decode_token(token, "access")
|
||||
user = await session.get(AppUser, user_id)
|
||||
if user is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
if user.status != "active":
|
||||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
|
||||
# 绑定 user_id:member 表策略允许"看到自己的成员记录",用于"我加入了哪些工作区"
|
||||
await bind_user(session, user.id)
|
||||
user_id_ctx.set(str(user.id))
|
||||
return AuthUser(id=user.id, phone=user.phone, nickname=user.nickname)
|
||||
|
||||
|
||||
CurrentUser = Annotated[AuthUser, Depends(current_user)]
|
||||
|
||||
|
||||
async def tenant_ctx(
|
||||
ws_id: Annotated[uuid.UUID, Path(alias="ws_id")],
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
) -> TenantCtx:
|
||||
"""仅绑定租户(不要求是成员)。用于"凭邀请码加入工作区"这类场景。"""
|
||||
await bind_tenant(session, ws_id, user.id)
|
||||
workspace_id_ctx.set(str(ws_id))
|
||||
return TenantCtx(ws_id=ws_id, user=user, role="")
|
||||
|
||||
|
||||
TenantDep = Annotated[TenantCtx, Depends(tenant_ctx)]
|
||||
|
||||
|
||||
async def member_ctx(ctx: TenantDep, session: SessionDep) -> TenantCtx:
|
||||
"""要求是有效成员,并把角色带进上下文。"""
|
||||
row = await session.execute(
|
||||
select(Member).where(
|
||||
Member.workspace_id == ctx.ws_id,
|
||||
Member.user_id == ctx.user.id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
member = row.scalar_one_or_none()
|
||||
if member is None:
|
||||
# 不区分"工作区不存在"和"你不是成员",避免探测他人工作区是否存在
|
||||
raise AppError(ErrorCode.NOT_A_MEMBER, "无权访问该工作区")
|
||||
return TenantCtx(ws_id=ctx.ws_id, user=ctx.user, role=member.role)
|
||||
|
||||
|
||||
MemberDep = Annotated[TenantCtx, Depends(member_ctx)]
|
||||
|
||||
|
||||
def require_role(minimum: str):
|
||||
async def _guard(ctx: MemberDep) -> TenantCtx:
|
||||
ctx.require_role(minimum)
|
||||
return ctx
|
||||
|
||||
return _guard
|
||||
|
||||
|
||||
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
|
||||
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
|
||||
Reference in New Issue
Block a user