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:
Steven
2026-10-28 02:38:53 +08:00
commit 0c53aa7062
72 changed files with 5320 additions and 0 deletions
View File
View File
+142
View File
@@ -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
],
}
)
+67
View File
@@ -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]
+240
View File
@@ -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)
+35
View File
@@ -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()
+93
View File
@@ -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
+94
View File
@@ -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
+114
View File
@@ -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"))]
+84
View File
@@ -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)
+80
View File
@@ -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)
+132
View File
@@ -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,
}
+142
View File
@@ -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,
}
+135
View File
@@ -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")
+77
View File
@@ -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)
+80
View File
@@ -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)
+111
View File
@@ -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}"