Files
DadaoTask/backend/app/deps.py
T
Steven 0c53aa7062 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
2026-10-28 02:38:53 +08:00

115 lines
3.6 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.
"""请求依赖:认证、租户上下文、角色校验。
关键点:**租户上下文必须在任何业务查询之前绑定**,否则 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"))]