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)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""审计日志写入。所有敏感操作(建工作区、邀请、加入、移除成员)都要留痕。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import AuditLog
|
||||
|
||||
|
||||
async def record(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
workspace_id: uuid.UUID | None,
|
||||
actor_user_id: uuid.UUID | None,
|
||||
action: str,
|
||||
target_type: str = "",
|
||||
target_id: str = "",
|
||||
payload: dict[str, Any] | None = None,
|
||||
ip: str = "",
|
||||
) -> None:
|
||||
session.add(
|
||||
AuditLog(
|
||||
workspace_id=workspace_id,
|
||||
actor_user_id=actor_user_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
payload=payload or {},
|
||||
ip=ip,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""分层配置:dev / prod 全部走环境变量,启动即校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
DEV_JWT_SECRET = "dev-only-change-me"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False
|
||||
)
|
||||
|
||||
# ── 基础 ──────────────────────────────────────────────
|
||||
app_name: str = "达道Task"
|
||||
env: Literal["dev", "test", "prod"] = "dev"
|
||||
api_prefix: str = "/api/v1"
|
||||
cors_origins: list[str] = Field(
|
||||
default_factory=lambda: ["http://localhost:5180", "http://127.0.0.1:5180"]
|
||||
)
|
||||
|
||||
# ── 数据库 ────────────────────────────────────────────
|
||||
# 应用角色:受 RLS 约束,非 owner、非 superuser
|
||||
database_url: str = (
|
||||
"postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||
)
|
||||
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
|
||||
migration_database_url: str = (
|
||||
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||||
)
|
||||
db_echo: bool = False
|
||||
db_pool_size: int = 10
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────
|
||||
redis_url: str = "redis://127.0.0.1:6380/0"
|
||||
|
||||
# ── 认证 ──────────────────────────────────────────────
|
||||
jwt_secret: str = DEV_JWT_SECRET
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_minutes: int = 30
|
||||
refresh_token_days: int = 14
|
||||
sms_code_ttl_seconds: int = 300
|
||||
sms_dev_code: str = "123456"
|
||||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||||
|
||||
# ── 幂等 ──────────────────────────────────────────────
|
||||
idempotency_enabled: bool = True
|
||||
idempotency_ttl_seconds: int = 86_400
|
||||
|
||||
# ── 构建标记(部署校验用)──────────────────────────────
|
||||
build_commit: str = "unknown"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@classmethod
|
||||
def _split_origins(cls, value: object) -> object:
|
||||
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
|
||||
if isinstance(value, str) and not value.startswith("["):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return value
|
||||
|
||||
def validate_runtime(self) -> None:
|
||||
"""生产环境的致命配置错误,必须在启动时炸掉,而不是等待请求。"""
|
||||
if self.env != "prod":
|
||||
return
|
||||
problems: list[str] = []
|
||||
if self.jwt_secret == DEV_JWT_SECRET or len(self.jwt_secret) < 32:
|
||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||
if "dadao_dev_pw" in self.database_url:
|
||||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||||
if self.sms_provider == "dev":
|
||||
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
|
||||
if problems:
|
||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def build_commit() -> str:
|
||||
"""优先读环境变量(CI/镜像注入),退回 git。"""
|
||||
env_value = os.getenv("BUILD_COMMIT")
|
||||
if env_value:
|
||||
return env_value
|
||||
return get_settings().build_commit
|
||||
@@ -0,0 +1,94 @@
|
||||
"""数据库会话与租户上下文。
|
||||
|
||||
安全模型(务必理解,否则会写出越权 bug):
|
||||
|
||||
1. 应用进程用 **dadao_app** 角色连库 —— 非 owner、非 superuser、非 bypassrls。
|
||||
owner/superuser 会**绕过 RLS**,所以应用绝不使用迁移角色。
|
||||
2. 每个请求在**同一个事务**里先 `set_config('app.ws_id', ..., true)`
|
||||
(true = 事务级,事务结束自动失效,不会污染连接池里的下一条请求)。
|
||||
3. RLS 策略拿这个变量做过滤。变量没设 → 策略为 false → 一行都查不到。
|
||||
4. 仓储层强制 ws_id 必填,双保险:即使 RLS 被误关,代码也不会跨租户查。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from .config import get_settings
|
||||
from .errors import AppError, ErrorCode
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
_engine_kwargs: dict[str, Any] = {"echo": _settings.db_echo, "pool_pre_ping": True}
|
||||
if _settings.env == "test":
|
||||
# pytest-asyncio 每个用例一个事件循环,连接池跨循环复用会报
|
||||
# "attached to a different loop",测试环境干脆不池化。
|
||||
_engine_kwargs["poolclass"] = NullPool
|
||||
else:
|
||||
_engine_kwargs["pool_size"] = _settings.db_pool_size
|
||||
|
||||
engine = create_async_engine(_settings.database_url, **_engine_kwargs)
|
||||
SessionFactory = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""一个请求 = 一个事务。异常即整体回滚。"""
|
||||
async with SessionFactory() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
|
||||
|
||||
async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None:
|
||||
"""把租户上下文写进当前事务。必须在任何业务查询之前调用。"""
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)}
|
||||
)
|
||||
if user_id is not None:
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
|
||||
)
|
||||
|
||||
|
||||
async def bind_user(session: AsyncSession, user_id: uuid.UUID) -> None:
|
||||
"""只绑用户(登录后尚未选定工作区的场景)。"""
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
|
||||
)
|
||||
|
||||
|
||||
async def current_app_role(session: AsyncSession) -> dict[str, Any]:
|
||||
"""自检用:确认应用连接的不是特权角色。"""
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT current_user AS role, "
|
||||
"pg_has_role(current_user, 'pg_read_all_data', 'member') AS read_all, "
|
||||
"(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_super, "
|
||||
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
|
||||
)
|
||||
)
|
||||
).mappings().one()
|
||||
return dict(row)
|
||||
|
||||
|
||||
class Repo:
|
||||
"""仓储基类:ws_id 必填,且没有默认值——不给"忘了传"留余地。"""
|
||||
|
||||
def __init__(self, session: AsyncSession, ws_id: uuid.UUID) -> None:
|
||||
if ws_id is None:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "缺少工作区上下文")
|
||||
self.session = session
|
||||
self.ws_id = ws_id
|
||||
|
||||
|
||||
class UserRepo:
|
||||
"""全局仓储(app_user 无租户),仅认证流程使用。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
@@ -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"))]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""错误码与异常。错误码一旦对外发布就**不许改号**(客户端靠它做分支)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCode(IntEnum):
|
||||
OK = 0
|
||||
|
||||
# 1xxx 通用
|
||||
BAD_REQUEST = 1001
|
||||
VALIDATION_FAILED = 1002
|
||||
NOT_FOUND = 1004
|
||||
CONFLICT = 1009
|
||||
PAYLOAD_TOO_LARGE = 1013
|
||||
RATE_LIMITED = 1029
|
||||
INTERNAL = 1500
|
||||
|
||||
# 2xxx 认证 / 账号
|
||||
UNAUTHORIZED = 2001
|
||||
TOKEN_EXPIRED = 2002
|
||||
TOKEN_INVALID = 2003
|
||||
SMS_CODE_INVALID = 2010
|
||||
SMS_TOO_FREQUENT = 2011
|
||||
ACCOUNT_DISABLED = 2012
|
||||
|
||||
# 3xxx 租户 / 权限
|
||||
WORKSPACE_NOT_FOUND = 3001
|
||||
NOT_A_MEMBER = 3003
|
||||
INSUFFICIENT_ROLE = 3004
|
||||
ALREADY_A_MEMBER = 3005
|
||||
INVITATION_INVALID = 3010
|
||||
|
||||
# 4xxx 并发
|
||||
VERSION_CONFLICT = 4009
|
||||
|
||||
|
||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.OK: 200,
|
||||
ErrorCode.BAD_REQUEST: 400,
|
||||
ErrorCode.VALIDATION_FAILED: 422,
|
||||
ErrorCode.NOT_FOUND: 404,
|
||||
ErrorCode.CONFLICT: 409,
|
||||
ErrorCode.PAYLOAD_TOO_LARGE: 413,
|
||||
ErrorCode.RATE_LIMITED: 429,
|
||||
ErrorCode.INTERNAL: 500,
|
||||
ErrorCode.UNAUTHORIZED: 401,
|
||||
ErrorCode.TOKEN_EXPIRED: 401,
|
||||
ErrorCode.TOKEN_INVALID: 401,
|
||||
ErrorCode.SMS_CODE_INVALID: 400,
|
||||
ErrorCode.SMS_TOO_FREQUENT: 429,
|
||||
ErrorCode.ACCOUNT_DISABLED: 403,
|
||||
ErrorCode.WORKSPACE_NOT_FOUND: 404,
|
||||
ErrorCode.NOT_A_MEMBER: 403,
|
||||
ErrorCode.INSUFFICIENT_ROLE: 403,
|
||||
ErrorCode.ALREADY_A_MEMBER: 409,
|
||||
ErrorCode.INVITATION_INVALID: 400,
|
||||
ErrorCode.VERSION_CONFLICT: 409,
|
||||
}
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""业务异常。__str__ 只用于日志,对外一律走 code + message。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: ErrorCode,
|
||||
message: str | None = None,
|
||||
*,
|
||||
http_status: int | None = None,
|
||||
details: Any = None,
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.message = message or code.name
|
||||
self.http_status = http_status or _HTTP_STATUS.get(code, 400)
|
||||
self.details = details
|
||||
super().__init__(f"[{int(code)}] {self.message}")
|
||||
|
||||
|
||||
def http_status_for(code: ErrorCode) -> int:
|
||||
return _HTTP_STATUS.get(code, 400)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""结构化日志:JSON 行 + request_id 贯穿 + 敏感字段脱敏。
|
||||
|
||||
脱敏是**无条件**的:任何日志调用里的 dict 都会过一遍 _mask。
|
||||
不要为了方便调试关掉它——AGENTS.md 第 7 条。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
|
||||
user_id_ctx: ContextVar[str] = ContextVar("user_id", default="-")
|
||||
workspace_id_ctx: ContextVar[str] = ContextVar("workspace_id", default="-")
|
||||
|
||||
_SENSITIVE_HINTS = (
|
||||
"password",
|
||||
"passwd",
|
||||
"token",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"credential",
|
||||
"private_key",
|
||||
)
|
||||
MASK = "***"
|
||||
|
||||
|
||||
def _mask(value: Any, depth: int = 0) -> Any:
|
||||
if depth > 6:
|
||||
return "<deep>"
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (MASK if any(h in str(key).lower() for h in _SENSITIVE_HINTS) else _mask(val, depth + 1))
|
||||
for key, val in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_mask(item, depth + 1) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
"request_id": request_id_ctx.get(),
|
||||
"user_id": user_id_ctx.get(),
|
||||
"ws_id": workspace_id_ctx.get(),
|
||||
}
|
||||
extra = getattr(record, "extra_fields", None)
|
||||
if isinstance(extra, dict):
|
||||
payload.update(_mask(extra))
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO") -> None:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler]
|
||||
root.setLevel(level)
|
||||
# uvicorn 的 access log 交给自己的中间件,避免重复
|
||||
for noisy in ("uvicorn.access", "sqlalchemy.engine.Engine"):
|
||||
logging.getLogger(noisy).handlers = []
|
||||
logging.getLogger(noisy).propagate = False
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""FastAPI 入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from .api import auth, workspaces
|
||||
from .config import get_settings
|
||||
from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
from .logging_setup import get_logger, request_id_ctx, setup_logging
|
||||
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging("DEBUG" if settings.env == "dev" else "INFO")
|
||||
logger = get_logger("app")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.validate_runtime()
|
||||
logger.info("服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}})
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=f"{settings.app_name} API",
|
||||
version="0.1.0",
|
||||
description="达道Task —— 任务 / 工单 / 知识库,一条流。",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.env != "prod" else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Request-Id", "X-Idempotent-Replay"],
|
||||
)
|
||||
# 后加的中间件先执行:RequestContext 必须最外层
|
||||
app.add_middleware(IdempotencyMiddleware)
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
|
||||
# ── 异常处理:对外永远是 {code, message, data, request_id} ──
|
||||
@app.exception_handler(AppError)
|
||||
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.http_status,
|
||||
content=error_body(request_id_ctx.get(), exc.code, exc.message, exc.details),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
details: list[dict[str, Any]] = [
|
||||
{"field": ".".join(str(p) for p in err["loc"][1:]), "msg": err["msg"]}
|
||||
for err in exc.errors()
|
||||
]
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=error_body(
|
||||
request_id_ctx.get(), ErrorCode.VALIDATION_FAILED, "参数校验失败", details
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def handle_http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
code = ErrorCode.NOT_FOUND if exc.status_code == 404 else ErrorCode.BAD_REQUEST
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_body(request_id_ctx.get(), code, str(exc.detail)),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.exception("未处理异常")
|
||||
return JSONResponse(
|
||||
status_code=http_status_for(ErrorCode.INTERNAL),
|
||||
content=error_body(request_id_ctx.get(), ErrorCode.INTERNAL, "服务器内部错误"),
|
||||
)
|
||||
|
||||
|
||||
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||
app.include_router(workspaces.router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||
async def health() -> dict:
|
||||
db_ok = True
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("数据库探活失败")
|
||||
db_ok = False
|
||||
return {"status": "ok" if db_ok else "degraded", "db": db_ok, "env": settings.env}
|
||||
|
||||
|
||||
@app.get("/version", tags=["运维"], summary="构建标记")
|
||||
async def version() -> dict:
|
||||
"""部署后必须校验:commit 必须等于 git rev-parse --short HEAD。"""
|
||||
role = None
|
||||
try:
|
||||
async with SessionFactory() as session:
|
||||
role = await current_app_role(session)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("读取数据库角色失败")
|
||||
|
||||
return {
|
||||
"app": settings.app_name,
|
||||
"env": settings.env,
|
||||
"commit": settings.build_commit,
|
||||
"build_time": settings.build_time,
|
||||
"api_prefix": settings.api_prefix,
|
||||
"db_role": role,
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""中间件:请求上下文(request_id / 访问日志)与幂等键。
|
||||
|
||||
顺序:RequestContext → Idempotency → 路由。
|
||||
request_id 必须最先建立,否则后面所有日志都没有 trace。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from .config import get_settings
|
||||
from .errors import ErrorCode
|
||||
from .logging_setup import get_logger, request_id_ctx
|
||||
from .security import decode_token
|
||||
from .stores import dumps, get_store, idempotency_key
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger("app.access")
|
||||
|
||||
_IDEMPOTENT_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
_MAX_CACHE_BYTES = 256 * 1024
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
rid = request.headers.get("X-Request-Id") or uuid.uuid4().hex
|
||||
token = request_id_ctx.set(rid)
|
||||
started = time.perf_counter()
|
||||
status = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status = response.status_code
|
||||
response.headers["X-Request-Id"] = rid
|
||||
return response
|
||||
finally:
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
logger.info(
|
||||
"%s %s %s %sms",
|
||||
request.method,
|
||||
request.url.path,
|
||||
status,
|
||||
elapsed_ms,
|
||||
extra={
|
||||
"extra_fields": {
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": status,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"client": request.client.host if request.client else "-",
|
||||
}
|
||||
},
|
||||
)
|
||||
request_id_ctx.reset(token)
|
||||
|
||||
|
||||
class IdempotencyMiddleware(BaseHTTPMiddleware):
|
||||
"""带 Idempotency-Key 的写请求,重复提交返回第一次的结果。
|
||||
|
||||
限制(M0 已知):同 key 并发请求存在竞态窗口,靠唯一键约束兜底留到业务层;
|
||||
只缓存 JSON 且 < 256KB 的响应;5xx 不缓存。
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if not settings.idempotency_enabled or request.method not in _IDEMPOTENT_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
raw_key = request.headers.get("Idempotency-Key")
|
||||
if not raw_key:
|
||||
return await call_next(request)
|
||||
|
||||
subject = self._subject(request)
|
||||
if subject is None:
|
||||
# 未认证请求不做幂等(登录接口另有验证码限流)
|
||||
return await call_next(request)
|
||||
|
||||
store = get_store()
|
||||
cache_key = idempotency_key(subject, raw_key)
|
||||
try:
|
||||
cached = await store.get(cache_key)
|
||||
except Exception: # noqa: BLE001 - 存储不可用不能拖垮业务
|
||||
logger.warning("幂等存储不可用,降级为直接执行", exc_info=True)
|
||||
return await call_next(request)
|
||||
|
||||
if cached:
|
||||
payload = json.loads(cached)
|
||||
return JSONResponse(
|
||||
content=payload["body"],
|
||||
status_code=payload["status"],
|
||||
headers={"X-Idempotent-Replay": "1"},
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
if response.status_code >= 500:
|
||||
return response
|
||||
|
||||
body = b""
|
||||
async for chunk in response.body_iterator: # type: ignore[attr-defined]
|
||||
body += chunk.encode() if isinstance(chunk, str) else chunk
|
||||
if len(body) <= _MAX_CACHE_BYTES and "application/json" in response.headers.get(
|
||||
"content-type", ""
|
||||
):
|
||||
try:
|
||||
await store.set(
|
||||
cache_key,
|
||||
dumps({"status": response.status_code, "body": json.loads(body)}),
|
||||
settings.idempotency_ttl_seconds,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("幂等结果写入失败", exc_info=True)
|
||||
|
||||
return Response(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _subject(request: Request) -> str | None:
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.lower().startswith("bearer "):
|
||||
return None
|
||||
try:
|
||||
return str(decode_token(header.split(" ", 1)[1].strip(), "access"))
|
||||
except Exception: # noqa: BLE001 - 令牌问题交给后面的依赖统一报错
|
||||
return None
|
||||
|
||||
|
||||
def error_body(request_id: str, code: ErrorCode, message: str, details: Any = None) -> dict:
|
||||
return {
|
||||
"code": int(code),
|
||||
"message": message,
|
||||
"data": details,
|
||||
"request_id": request_id,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""数据模型(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] = mapped_column(String(32), nullable=False, 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")
|
||||
|
||||
|
||||
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 的租户表(迁移与测试共用这一份清单,避免漏加)
|
||||
TENANT_TABLES: tuple[str, ...] = ("workspace", "member", "invitation", "audit_log")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""统一响应体 {code, message, data, request_id} + 游标分页。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .errors import AppError, ErrorCode
|
||||
from .logging_setup import request_id_ctx
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
|
||||
|
||||
class Envelope(BaseModel, Generic[T]):
|
||||
code: int = 0
|
||||
message: str = "ok"
|
||||
data: T | None = None
|
||||
request_id: str = "-"
|
||||
|
||||
|
||||
class Page(BaseModel, Generic[T]):
|
||||
items: list[T]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class Cursor(BaseModel):
|
||||
"""(created_at, id) 复合游标:单用 created_at 会在同毫秒记录上漏数据。"""
|
||||
|
||||
created_at: datetime
|
||||
id: UUID
|
||||
|
||||
|
||||
def ok(data: Any = None, message: str = "ok") -> dict[str, Any]:
|
||||
return {
|
||||
"code": int(ErrorCode.OK),
|
||||
"message": message,
|
||||
"data": data,
|
||||
"request_id": request_id_ctx.get(),
|
||||
}
|
||||
|
||||
|
||||
def encode_cursor(created_at: datetime, row_id: UUID) -> str:
|
||||
raw = json.dumps({"t": created_at.isoformat(), "i": str(row_id)}, separators=(",", ":"))
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(cursor: str) -> Cursor:
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded))
|
||||
return Cursor(created_at=datetime.fromisoformat(payload["t"]), id=UUID(payload["i"]))
|
||||
except (binascii.Error, KeyError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "分页游标不合法") from exc
|
||||
|
||||
|
||||
def clamp_limit(limit: int) -> int:
|
||||
if limit <= 0:
|
||||
return DEFAULT_LIMIT
|
||||
return min(limit, MAX_LIMIT)
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
limit: int = Field(default=DEFAULT_LIMIT, ge=1, le=MAX_LIMIT)
|
||||
cursor: str | None = None
|
||||
|
||||
def parsed(self) -> tuple[int, Cursor | None]:
|
||||
return clamp_limit(self.limit), (decode_cursor(self.cursor) if self.cursor else None)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""令牌与验证码。
|
||||
|
||||
安全约定:
|
||||
- access token 短期(默认 30 分钟),refresh token 长期(14 天),两者 type 不同,不可互换。
|
||||
- 令牌里不放手机号等 PII,只放 sub=user_id。
|
||||
- 验证码只在服务端比对,**不写日志**;开发环境返回固定码仅用于本地联调。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import jwt
|
||||
|
||||
from .config import get_settings
|
||||
from .errors import AppError, ErrorCode
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
TokenType = Literal["access", "refresh"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenPair:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
def _new_token(user_id: uuid.UUID, token_type: TokenType, ttl_seconds: int) -> str:
|
||||
now = int(time.time())
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"typ": token_type,
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
"jti": secrets.token_urlsafe(8),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def create_token_pair(user_id: uuid.UUID) -> TokenPair:
|
||||
access_ttl = settings.access_token_minutes * 60
|
||||
return TokenPair(
|
||||
access_token=_new_token(user_id, "access", access_ttl),
|
||||
refresh_token=_new_token(user_id, "refresh", settings.refresh_token_days * 86400),
|
||||
expires_in=access_ttl,
|
||||
)
|
||||
|
||||
|
||||
def decode_token(token: str, expect: TokenType) -> uuid.UUID:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise AppError(ErrorCode.TOKEN_EXPIRED, "登录已过期,请重新登录") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AppError(ErrorCode.TOKEN_INVALID, "令牌无效") from exc
|
||||
|
||||
if payload.get("typ") != expect:
|
||||
# 用 refresh 当 access 用是常见攻击面,必须拦
|
||||
raise AppError(ErrorCode.TOKEN_INVALID, "令牌类型不匹配")
|
||||
try:
|
||||
return uuid.UUID(str(payload["sub"]))
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise AppError(ErrorCode.TOKEN_INVALID, "令牌内容不合法") from exc
|
||||
|
||||
|
||||
def new_invite_code() -> str:
|
||||
# 去掉容易看错的 0/O/1/I
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
|
||||
def invite_code_matches(provided: str, expected: str) -> bool:
|
||||
return hmac.compare_digest(provided.strip().upper(), expected)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""短生命周期状态存储:验证码、幂等键。
|
||||
|
||||
dev/test 用进程内实现(不依赖 Redis 也能跑测试),prod 强制走 Redis。
|
||||
接口刻意做小,将来换实现不影响调用方。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class KVStore(Protocol):
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None: ...
|
||||
|
||||
async def get(self, key: str) -> str | None: ...
|
||||
|
||||
async def delete(self, key: str) -> None: ...
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int: ...
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
"""进程内 TTL 字典。仅限单进程 dev/test——多 worker 下不共享,别在生产用。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._data: dict[str, tuple[str, float]] = {}
|
||||
|
||||
def _purge(self, key: str) -> None:
|
||||
item = self._data.get(key)
|
||||
if item and item[1] < time.time():
|
||||
self._data.pop(key, None)
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
self._data[key] = (value, time.time() + ttl_seconds)
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
self._purge(key)
|
||||
item = self._data.get(key)
|
||||
return item[0] if item else None
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
self._data.pop(key, None)
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
self._purge(key)
|
||||
current = int(self._data[key][0]) if key in self._data else 0
|
||||
current += 1
|
||||
expire_at = self._data[key][1] if key in self._data else time.time() + ttl_seconds
|
||||
self._data[key] = (str(current), expire_at)
|
||||
return current
|
||||
|
||||
|
||||
class RedisStore:
|
||||
def __init__(self, url: str) -> None:
|
||||
from redis.asyncio import Redis # 延迟导入:dev 环境没有 redis 包也能起
|
||||
|
||||
self._client = Redis.from_url(url, decode_responses=True)
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
await self._client.set(key, value, ex=ttl_seconds)
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
return await self._client.get(key)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
await self._client.delete(key)
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
value = await self._client.incr(key)
|
||||
if value == 1:
|
||||
await self._client.expire(key, ttl_seconds)
|
||||
return int(value)
|
||||
|
||||
|
||||
_store: KVStore | None = None
|
||||
|
||||
|
||||
def get_store() -> KVStore:
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = RedisStore(settings.redis_url) if settings.env == "prod" else MemoryStore()
|
||||
return _store
|
||||
|
||||
|
||||
def reset_store_for_tests() -> None:
|
||||
"""测试夹具用:清掉进程内状态。"""
|
||||
global _store
|
||||
_store = None
|
||||
|
||||
|
||||
def dumps(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
# ── 业务键约定 ──────────────────────────────────────────
|
||||
def sms_code_key(phone: str) -> str:
|
||||
return f"sms:login:{phone}"
|
||||
|
||||
|
||||
def sms_throttle_key(phone: str) -> str:
|
||||
return f"sms:throttle:{phone}"
|
||||
|
||||
|
||||
def idempotency_key(user_id: str, key: str) -> str:
|
||||
return f"idem:{user_id}:{key}"
|
||||
Reference in New Issue
Block a user