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
|
||||
],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user