重构表结构:合并 profiles→users,展平 API 响应,新增计数器字段

- models: 删除 Profile 模型,字段合并到 User;User 新增云朵/公开/收藏计数器;Cloud 新增 favorite_count
- schemas/serializers: 展平 UserOut/AuthOut/MeOut/AdminUserOut,移除嵌套 profile
- deps: user.profile.role→user.role,移除 selectinload profile
- auth/register: 注册时直接设置 user 字段,不再创建 Profile
- clouds: 创建/删除云朵时同步 user.cloud_count 计数器,状态变化时同步 public_cloud_count
- favorites: 点赞/取消时同步 cloud.favorite_count 计数器
- admin: 审批/隐藏/批量删时同步 public_cloud_count 计数器
- profiles/stats: 用计数器替代实时 COUNT 查询
- alembic: 新增 migration 合并 profiles 到 users,初始化 counters
This commit is contained in:
2026-07-28 23:48:57 +08:00
parent 118c5cbe96
commit e75833ee4b
14 changed files with 519 additions and 285 deletions
+53 -42
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.config import settings
from app.deps import CurrentUser, DbSession
from app.models import EmailToken, Profile, RefreshSession, User
from app.models import EmailToken, RefreshSession, User
from app.schemas import (
AuthOut,
ChangePasswordIn,
@@ -30,7 +30,7 @@ from app.security import (
utc_now,
verify_password,
)
from app.serializers import profile_out, user_out
from app.serializers import user_out
from app.services.email import send_confirmation_email, send_password_reset_email
@@ -72,28 +72,6 @@ def _clear_refresh_cookie(response: Response) -> None:
)
def _auth_response(user: User, session: RefreshSession) -> AuthOut:
return AuthOut(
access_token=create_access_token(user.id, session.id),
expires_in=settings.access_token_expire_minutes * 60,
user=user_out(user),
profile=profile_out(user.profile),
)
def _new_session(user: User, request: Request) -> tuple[RefreshSession, str]:
token = create_random_token()
user_agent, ip_address = _client_metadata(request)
session = RefreshSession(
user_id=user.id,
token_hash=hash_token(token),
expires_at=refresh_expires_at(),
user_agent=user_agent,
ip_address=ip_address,
)
return session, token
async def _create_email_token(db: DbSession, user: User, purpose: str, expires_delta: timedelta) -> str:
now = utc_now()
await db.execute(
@@ -120,8 +98,7 @@ async def _create_email_token(db: DbSession, user: User, purpose: str, expires_d
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterIn, db: DbSession) -> MessageOut:
email = str(payload.email).strip().lower()
user = User(email=email, password_hash=hash_password(payload.password))
user.profile = Profile(username=payload.username)
user = User(email=email, password_hash=hash_password(payload.password), username=payload.username)
db.add(user)
try:
await db.flush()
@@ -194,9 +171,7 @@ async def confirm_email(payload: TokenIn, db: DbSession) -> MessageOut:
@router.post("/login", response_model=AuthOut)
async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut:
email = str(payload.email).strip().lower()
result = await db.execute(
select(User).options(selectinload(User.profile)).where(User.email == email)
)
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH
password_valid = verify_password(payload.password, encoded_hash)
@@ -204,14 +179,26 @@ async def login(payload: LoginIn, request: Request, response: Response, db: DbSe
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
if user.email_verified_at is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="邮箱尚未确认")
if user.profile.is_disabled:
if user.is_disabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
session, refresh_token = _new_session(user, request)
token = create_random_token()
user_agent, ip_address = _client_metadata(request)
session = RefreshSession(
user_id=user.id,
token_hash=hash_token(token),
expires_at=refresh_expires_at(),
user_agent=user_agent,
ip_address=ip_address,
)
db.add(session)
await db.commit()
_set_refresh_cookie(response, refresh_token)
return _auth_response(user, session)
_set_refresh_cookie(response, token)
return AuthOut(
access_token=create_access_token(user.id, session.id),
expires_in=settings.access_token_expire_minutes * 60,
user=user_out(user),
)
@router.post("/refresh", response_model=AuthOut)
@@ -222,7 +209,7 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu
now = utc_now()
result = await db.execute(
select(RefreshSession)
.options(selectinload(RefreshSession.user).selectinload(User.profile))
.options(selectinload(RefreshSession.user))
.where(RefreshSession.token_hash == hash_token(raw_token))
.with_for_update()
)
@@ -235,20 +222,32 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu
_clear_refresh_cookie(response)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期")
user = old_session.user
if user.profile.is_disabled:
if user.is_disabled:
old_session.revoked_at = now
await db.commit()
_clear_refresh_cookie(response)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
new_session, new_token = _new_session(user, request)
token = create_random_token()
user_agent, ip_address = _client_metadata(request)
new_session = RefreshSession(
user_id=user.id,
token_hash=hash_token(token),
expires_at=refresh_expires_at(),
user_agent=user_agent,
ip_address=ip_address,
)
db.add(new_session)
await db.flush()
old_session.revoked_at = now
old_session.replaced_by_id = new_session.id
await db.commit()
_set_refresh_cookie(response, new_token)
return _auth_response(user, new_session)
_set_refresh_cookie(response, token)
return AuthOut(
access_token=create_access_token(user.id, new_session.id),
expires_in=settings.access_token_expire_minutes * 60,
user=user_out(user),
)
@router.post("/logout", response_model=MessageOut)
@@ -268,7 +267,7 @@ async def logout(request: Request, response: Response, db: DbSession) -> Message
@router.get("/me", response_model=MeOut)
async def me(user: CurrentUser) -> MeOut:
return MeOut(user=user_out(user), profile=profile_out(user.profile))
return MeOut(user=user_out(user))
@router.post("/forgot-password", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
@@ -336,8 +335,20 @@ async def change_password(
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
.values(revoked_at=now)
)
new_session, refresh_token = _new_session(user, request)
token = create_random_token()
user_agent, ip_address = _client_metadata(request)
new_session = RefreshSession(
user_id=user.id,
token_hash=hash_token(token),
expires_at=refresh_expires_at(),
user_agent=user_agent,
ip_address=ip_address,
)
db.add(new_session)
await db.commit()
_set_refresh_cookie(response, refresh_token)
return _auth_response(user, new_session)
_set_refresh_cookie(response, token)
return AuthOut(
access_token=create_access_token(user.id, new_session.id),
expires_in=settings.access_token_expire_minutes * 60,
user=user_out(user),
)