重构表结构:合并 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:
+86
-16
@@ -7,10 +7,10 @@ from zoneinfo import ZoneInfo
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from sqlalchemy import case, func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.deps import AdminUser, DbSession
|
||||
from app.models import Cloud, Profile, User
|
||||
from app.models import Cloud, User
|
||||
from app.schemas import (
|
||||
AdminCloudStatusIn,
|
||||
AdminCloudVisibilityIn,
|
||||
@@ -25,7 +25,7 @@ from app.schemas import (
|
||||
PageOut,
|
||||
)
|
||||
from app.security import hash_password, utc_now
|
||||
from app.serializers import cloud_out, profile_out, user_out
|
||||
from app.serializers import cloud_out, user_out
|
||||
from app.services.storage import delete_files
|
||||
|
||||
|
||||
@@ -71,12 +71,11 @@ async def list_users(
|
||||
total = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.profile))
|
||||
.order_by(User.created_at.desc(), User.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = [AdminUserOut(user=user_out(user), profile=profile_out(user.profile)) for user in result.scalars()]
|
||||
items = [AdminUserOut(user=user_out(user)) for user in result.scalars()]
|
||||
return PageOut(
|
||||
items=items,
|
||||
page=page,
|
||||
@@ -97,8 +96,9 @@ async def create_user(
|
||||
email=email,
|
||||
password_hash=hash_password(payload.password),
|
||||
email_verified_at=utc_now(),
|
||||
username=payload.username,
|
||||
role=payload.role,
|
||||
)
|
||||
user.profile = Profile(username=payload.username, role=payload.role)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.commit()
|
||||
@@ -108,8 +108,8 @@ async def create_user(
|
||||
if "email" in message:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该邮箱已被注册") from exc
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
|
||||
await db.refresh(user, ["profile"])
|
||||
return AdminUserOut(user=user_out(user), profile=profile_out(user.profile))
|
||||
await db.refresh(user)
|
||||
return AdminUserOut(user=user_out(user))
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
||||
@@ -119,10 +119,7 @@ async def update_user(
|
||||
admin: AdminUser,
|
||||
db: DbSession,
|
||||
) -> AdminUserOut:
|
||||
result = await db.execute(
|
||||
select(User).options(selectinload(User.profile)).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user.id == admin.id and (
|
||||
@@ -130,11 +127,11 @@ async def update_user(
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
|
||||
if payload.role is not None:
|
||||
user.profile.role = payload.role
|
||||
user.role = payload.role
|
||||
if payload.is_disabled is not None:
|
||||
user.profile.is_disabled = payload.is_disabled
|
||||
user.is_disabled = payload.is_disabled
|
||||
await db.commit()
|
||||
return AdminUserOut(user=user_out(user), profile=profile_out(user.profile))
|
||||
return AdminUserOut(user=user_out(user))
|
||||
|
||||
|
||||
@router.get("/clouds", response_model=PageOut[CloudOut])
|
||||
@@ -154,7 +151,7 @@ async def list_admin_clouds(
|
||||
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||
result = await db.execute(
|
||||
select(Cloud)
|
||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||
.options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
|
||||
.where(*filters)
|
||||
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
@@ -182,6 +179,37 @@ async def update_cloud_status(
|
||||
db: DbSession,
|
||||
) -> BatchUpdateOut:
|
||||
await _require_all_cloud_ids(db, payload.ids)
|
||||
|
||||
if payload.status == "approved":
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(
|
||||
select(Cloud.user_id).where(
|
||||
Cloud.id.in_(payload.ids),
|
||||
Cloud.status != "approved",
|
||||
Cloud.is_hidden.is_(False),
|
||||
)
|
||||
))
|
||||
.values(public_cloud_count=User.public_cloud_count + 1)
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(
|
||||
select(Cloud.user_id).where(
|
||||
Cloud.id.in_(payload.ids),
|
||||
Cloud.status == "approved",
|
||||
Cloud.is_hidden.is_(False),
|
||||
)
|
||||
))
|
||||
.values(
|
||||
public_cloud_count=case(
|
||||
(User.public_cloud_count > 0, User.public_cloud_count - 1),
|
||||
else_=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
update(Cloud)
|
||||
.where(Cloud.id.in_(payload.ids))
|
||||
@@ -198,6 +226,37 @@ async def update_cloud_visibility(
|
||||
db: DbSession,
|
||||
) -> BatchUpdateOut:
|
||||
await _require_all_cloud_ids(db, payload.ids)
|
||||
|
||||
if payload.is_hidden:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(
|
||||
select(Cloud.user_id).where(
|
||||
Cloud.id.in_(payload.ids),
|
||||
Cloud.status == "approved",
|
||||
Cloud.is_hidden.is_(False),
|
||||
)
|
||||
))
|
||||
.values(
|
||||
public_cloud_count=case(
|
||||
(User.public_cloud_count > 0, User.public_cloud_count - 1),
|
||||
else_=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id.in_(
|
||||
select(Cloud.user_id).where(
|
||||
Cloud.id.in_(payload.ids),
|
||||
Cloud.status == "approved",
|
||||
Cloud.is_hidden.is_(True),
|
||||
)
|
||||
))
|
||||
.values(public_cloud_count=User.public_cloud_count + 1)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
update(Cloud)
|
||||
.where(Cloud.id.in_(payload.ids))
|
||||
@@ -218,8 +277,19 @@ async def delete_admin_clouds(
|
||||
if len(clouds) != len(payload.ids):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
|
||||
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
|
||||
|
||||
user_ids: dict[uuid.UUID, int] = {}
|
||||
for cloud in clouds:
|
||||
user_ids[cloud.user_id] = user_ids.get(cloud.user_id, 0) + 1
|
||||
await db.delete(cloud)
|
||||
|
||||
for uid, count in user_ids.items():
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == uid)
|
||||
.values(cloud_count=User.cloud_count - count)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await delete_files(paths)
|
||||
return DeleteResultOut(deleted=len(clouds))
|
||||
|
||||
+53
-42
@@ -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),
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ async def get_cloud_type_clouds(
|
||||
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||
result = await db.execute(
|
||||
select(Cloud)
|
||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||
.options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
|
||||
.where(*filters)
|
||||
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
|
||||
+73
-11
@@ -5,13 +5,13 @@ from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.deps import CurrentUser, DbSession, OptionalUser
|
||||
from app.models import Cloud, CloudType, Profile, User, UserCollection
|
||||
from app.models import Cloud, CloudFavorite, CloudType, User, UserCollection
|
||||
from app.schemas import (
|
||||
BadgeOut,
|
||||
BatchIdsIn,
|
||||
@@ -30,7 +30,7 @@ router = APIRouter(prefix="/clouds", tags=["云图"])
|
||||
|
||||
def _cloud_options():
|
||||
return (
|
||||
joinedload(Cloud.user).joinedload(User.profile),
|
||||
joinedload(Cloud.user),
|
||||
joinedload(Cloud.cloud_type),
|
||||
)
|
||||
|
||||
@@ -93,9 +93,9 @@ async def list_gallery_clouds(
|
||||
username = term[1:].strip()
|
||||
if not username:
|
||||
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||
data_query = data_query.join(Cloud.user).join(User.profile)
|
||||
count_query = count_query.join(Cloud.user).join(User.profile)
|
||||
filters.append(Profile.username.ilike(f"%{username}%"))
|
||||
data_query = data_query.join(Cloud.user)
|
||||
count_query = count_query.join(Cloud.user)
|
||||
filters.append(User.username.ilike(f"%{username}%"))
|
||||
elif term:
|
||||
data_query = data_query.outerjoin(Cloud.cloud_type)
|
||||
count_query = count_query.outerjoin(Cloud.cloud_type)
|
||||
@@ -158,10 +158,22 @@ async def get_cloud(cloud_id: uuid.UUID, db: DbSession, viewer: OptionalUser) ->
|
||||
cloud = await _get_cloud(db, cloud_id)
|
||||
if not cloud:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.profile.role == "admin"))
|
||||
can_manage = bool(viewer and (viewer.id == cloud.user_id or viewer.role == "admin"))
|
||||
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
return cloud_out(cloud, include_private=can_manage)
|
||||
|
||||
favorite_count = cloud.favorite_count
|
||||
is_favorited = False
|
||||
if viewer:
|
||||
fav_id = await db.scalar(
|
||||
select(CloudFavorite.id).where(
|
||||
CloudFavorite.user_id == viewer.id,
|
||||
CloudFavorite.cloud_id == cloud_id,
|
||||
)
|
||||
)
|
||||
is_favorited = fav_id is not None
|
||||
|
||||
return cloud_out(cloud, include_private=can_manage, favorite_count=favorite_count, is_favorited=is_favorited)
|
||||
|
||||
|
||||
@router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -207,6 +219,13 @@ async def create_cloud(
|
||||
unlocked_badge: BadgeOut | None = None
|
||||
try:
|
||||
await db.flush()
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(cloud_count=User.cloud_count + 1)
|
||||
)
|
||||
|
||||
if cloud_type:
|
||||
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
||||
insert_result = await db.execute(
|
||||
@@ -223,6 +242,11 @@ async def create_cloud(
|
||||
)
|
||||
unlocked_at = insert_result.scalar_one_or_none()
|
||||
if unlocked_at:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(collection_count=User.collection_count + 1)
|
||||
)
|
||||
unlocked_badge = BadgeOut(
|
||||
cloud_type_id=cloud_type.id,
|
||||
cloud_name=cloud_type.name,
|
||||
@@ -252,9 +276,10 @@ async def update_cloud(
|
||||
cloud = await _get_cloud(db, cloud_id)
|
||||
if not cloud:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
if cloud.user_id != user.id and user.profile.role != "admin":
|
||||
if cloud.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改其他用户的图片")
|
||||
|
||||
previous_public = cloud.status == "approved" and not cloud.is_hidden
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id)
|
||||
final_custom = _normalize_text(changes.get("custom_cloud_type", cloud.custom_cloud_type))
|
||||
@@ -284,8 +309,18 @@ async def update_cloud(
|
||||
cloud.captured_at = _normalize_datetime(changes["captured_at"])
|
||||
if "is_hidden" in changes:
|
||||
cloud.is_hidden = changes["is_hidden"]
|
||||
await db.commit()
|
||||
|
||||
await db.flush()
|
||||
new_public = cloud.status == "approved" and not cloud.is_hidden
|
||||
if previous_public != new_public:
|
||||
delta = 1 if new_public else -1
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == cloud.user_id)
|
||||
.values(public_cloud_count=User.public_cloud_count + delta)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
updated = await _get_cloud(db, cloud.id)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
@@ -297,10 +332,25 @@ async def delete_cloud(cloud_id: uuid.UUID, db: DbSession, user: CurrentUser) ->
|
||||
cloud = await _get_cloud(db, cloud_id)
|
||||
if not cloud:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
if cloud.user_id != user.id and user.profile.role != "admin":
|
||||
if cloud.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
|
||||
paths = [cloud.image_path, cloud.thumbnail_path]
|
||||
|
||||
was_public = cloud.status == "approved" and not cloud.is_hidden
|
||||
await db.delete(cloud)
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == cloud.user_id)
|
||||
.values(cloud_count=User.cloud_count - 1)
|
||||
)
|
||||
if was_public:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == cloud.user_id)
|
||||
.values(public_cloud_count=User.public_cloud_count - 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await delete_files(paths)
|
||||
return DeleteResultOut(deleted=1)
|
||||
@@ -318,9 +368,21 @@ async def batch_delete_clouds(
|
||||
clouds = result.scalars().all()
|
||||
if len(clouds) != len(payload.ids):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在或不属于当前用户")
|
||||
|
||||
public_count = sum(1 for c in clouds if c.status == "approved" and not c.is_hidden)
|
||||
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
|
||||
for cloud in clouds:
|
||||
await db.delete(cloud)
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
cloud_count=User.cloud_count - len(clouds),
|
||||
public_cloud_count=User.public_cloud_count - public_count,
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await delete_files(paths)
|
||||
return DeleteResultOut(deleted=len(clouds))
|
||||
|
||||
+26
-22
@@ -2,7 +2,7 @@ import math
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import case, delete, func, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import joinedload
|
||||
@@ -18,7 +18,7 @@ router = APIRouter(tags=["点赞收藏"])
|
||||
|
||||
def _cloud_options():
|
||||
return (
|
||||
joinedload(Cloud.user).joinedload(User.profile),
|
||||
joinedload(Cloud.user),
|
||||
joinedload(Cloud.cloud_type),
|
||||
)
|
||||
|
||||
@@ -35,23 +35,12 @@ async def _get_visible_cloud(
|
||||
if not cloud:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
|
||||
can_manage = cloud.user_id == user.id or user.profile.role == "admin"
|
||||
can_manage = cloud.user_id == user.id or user.role == "admin"
|
||||
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||
return cloud
|
||||
|
||||
|
||||
async def _favorite_count(db: DbSession, cloud_id: uuid.UUID) -> int:
|
||||
return (
|
||||
await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(CloudFavorite)
|
||||
.where(CloudFavorite.cloud_id == cloud_id)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
async def _is_favorited(db: DbSession, user_id: uuid.UUID, cloud_id: uuid.UUID) -> bool:
|
||||
favorite_id = await db.scalar(
|
||||
select(CloudFavorite.id).where(
|
||||
@@ -71,11 +60,12 @@ async def _favorite_out(
|
||||
) -> FavoriteOut:
|
||||
if favorited is None:
|
||||
favorited = await _is_favorited(db, user_id, cloud_id)
|
||||
cloud = await db.get(Cloud, cloud_id)
|
||||
return FavoriteOut(
|
||||
cloud_id=cloud_id,
|
||||
user_id=user_id,
|
||||
favorited=favorited,
|
||||
favorite_count=await _favorite_count(db, cloud_id),
|
||||
favorite_count=cloud.favorite_count if cloud else 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,11 +79,10 @@ async def get_cloud_favorite(
|
||||
description="要查询的用户 ID;默认当前登录用户。仅本人或管理员可查他人。",
|
||||
),
|
||||
) -> FavoriteOut:
|
||||
"""查询某用户对某张照片的点赞/收藏状态。"""
|
||||
await _get_visible_cloud(db, cloud_id, user)
|
||||
|
||||
target_user_id = user_id or user.id
|
||||
if target_user_id != user.id and user.profile.role != "admin":
|
||||
if target_user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只能查询自己的喜欢状态",
|
||||
@@ -109,27 +98,43 @@ async def set_cloud_favorite(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> FavoriteOut:
|
||||
"""点赞/收藏照片,或取消。"""
|
||||
await _get_visible_cloud(db, cloud_id, user)
|
||||
|
||||
if payload.favorited:
|
||||
insert_factory = (
|
||||
pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
||||
)
|
||||
await db.execute(
|
||||
result = await db.execute(
|
||||
insert_factory(CloudFavorite)
|
||||
.values(user_id=user.id, cloud_id=cloud_id)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=[CloudFavorite.user_id, CloudFavorite.cloud_id]
|
||||
)
|
||||
)
|
||||
if result.rowcount and result.rowcount > 0:
|
||||
await db.execute(
|
||||
update(Cloud)
|
||||
.where(Cloud.id == cloud_id)
|
||||
.values(favorite_count=Cloud.favorite_count + 1)
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
result = await db.execute(
|
||||
delete(CloudFavorite).where(
|
||||
CloudFavorite.user_id == user.id,
|
||||
CloudFavorite.cloud_id == cloud_id,
|
||||
)
|
||||
)
|
||||
if result.rowcount and result.rowcount > 0:
|
||||
await db.execute(
|
||||
update(Cloud)
|
||||
.where(Cloud.id == cloud_id)
|
||||
.values(
|
||||
favorite_count=case(
|
||||
(Cloud.favorite_count > 0, Cloud.favorite_count - 1),
|
||||
else_=0,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await _favorite_out(
|
||||
@@ -147,7 +152,6 @@ async def list_my_favorites(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
) -> PageOut[CloudOut]:
|
||||
"""当前用户点赞收藏的照片列表(按收藏时间倒序)。"""
|
||||
total = (
|
||||
await db.scalar(
|
||||
select(func.count())
|
||||
@@ -166,7 +170,7 @@ async def list_my_favorites(
|
||||
.limit(page_size)
|
||||
)
|
||||
items = [
|
||||
cloud_out(item, include_private=item.user_id == user.id or user.profile.role == "admin")
|
||||
cloud_out(item, include_private=item.user_id == user.id or user.role == "admin")
|
||||
for item in result.scalars().unique().all()
|
||||
]
|
||||
return PageOut(
|
||||
|
||||
+106
-68
@@ -3,21 +3,22 @@ import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.deps import CurrentUser, DbSession, OptionalUser
|
||||
from app.models import Cloud, Profile, User, UserCollection
|
||||
from app.models import Cloud, CloudFavorite, User, UserCollection
|
||||
from app.schemas import (
|
||||
CloudOut,
|
||||
LikeStatsOut,
|
||||
LikeStatsPhotoOut,
|
||||
PageOut,
|
||||
ProfileOut,
|
||||
ProfileStatsOut,
|
||||
ProfileUpdateIn,
|
||||
PublicProfileOut,
|
||||
UserOut,
|
||||
)
|
||||
from app.serializers import cloud_out, profile_out, public_profile_out
|
||||
from app.serializers import cloud_out, media_url, user_out
|
||||
from app.services.storage import delete_files, save_avatar
|
||||
|
||||
|
||||
@@ -29,60 +30,73 @@ router = APIRouter(prefix="/profiles", tags=["用户资料"])
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/me", response_model=PublicProfileOut)
|
||||
async def get_my_profile(user: CurrentUser, db: DbSession) -> PublicProfileOut:
|
||||
return public_profile_out(user.profile)
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_my_profile(user: CurrentUser) -> UserOut:
|
||||
return user_out(user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=ProfileOut)
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
async def update_my_profile(
|
||||
payload: ProfileUpdateIn,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> ProfileOut:
|
||||
user.profile.username = payload.username
|
||||
) -> UserOut:
|
||||
user.username = payload.username
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
|
||||
return profile_out(user.profile)
|
||||
return user_out(user)
|
||||
|
||||
|
||||
@router.post("/me/avatar", response_model=ProfileOut)
|
||||
@router.post("/me/avatar", response_model=UserOut)
|
||||
async def upload_avatar(
|
||||
image: Annotated[UploadFile, File()],
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> ProfileOut:
|
||||
if user.profile.avatar_path:
|
||||
old_path = user.profile.avatar_path
|
||||
user.profile.avatar_path = None
|
||||
) -> UserOut:
|
||||
if user.avatar_path:
|
||||
old_path = user.avatar_path
|
||||
user.avatar_path = None
|
||||
await db.commit()
|
||||
await delete_files([old_path])
|
||||
avatar_path = await save_avatar(image, user.id)
|
||||
user.profile.avatar_path = avatar_path
|
||||
user.avatar_path = avatar_path
|
||||
await db.commit()
|
||||
return profile_out(user.profile)
|
||||
return user_out(user)
|
||||
|
||||
|
||||
@router.delete("/me/avatar", response_model=ProfileOut)
|
||||
@router.delete("/me/avatar", response_model=UserOut)
|
||||
async def delete_avatar(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> ProfileOut:
|
||||
if not user.profile.avatar_path:
|
||||
) -> UserOut:
|
||||
if not user.avatar_path:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像")
|
||||
old_path = user.profile.avatar_path
|
||||
user.profile.avatar_path = None
|
||||
old_path = user.avatar_path
|
||||
user.avatar_path = None
|
||||
await db.commit()
|
||||
await delete_files([old_path])
|
||||
return profile_out(user.profile)
|
||||
return user_out(user)
|
||||
|
||||
|
||||
@router.get("/me/stats", response_model=ProfileStatsOut)
|
||||
async def get_my_stats(user: CurrentUser, db: DbSession) -> ProfileStatsOut:
|
||||
return await _build_stats(user.id, db)
|
||||
async def get_my_stats(user: CurrentUser) -> ProfileStatsOut:
|
||||
return ProfileStatsOut(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
created_at=user.created_at,
|
||||
cloud_count=user.cloud_count,
|
||||
public_cloud_count=user.public_cloud_count,
|
||||
collection_count=user.collection_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/like-stats", response_model=LikeStatsOut)
|
||||
async def get_my_like_stats(user: CurrentUser, db: DbSession) -> LikeStatsOut:
|
||||
return await _build_like_stats(user.id, db)
|
||||
|
||||
|
||||
@router.get("/me/clouds", response_model=PageOut[CloudOut])
|
||||
@@ -92,7 +106,7 @@ async def get_my_clouds(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
) -> PageOut[CloudOut]:
|
||||
return await _list_profile_clouds(user.id, db, viewer_role=user.profile.role, viewer_id=user.id, page=page, page_size=page_size)
|
||||
return await _list_profile_clouds(user.id, db, viewer_role=user.role, viewer_id=user.id, page=page, page_size=page_size)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,12 +114,12 @@ async def get_my_clouds(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=PublicProfileOut)
|
||||
async def get_profile(user_id: uuid.UUID, db: DbSession) -> PublicProfileOut:
|
||||
profile = await db.get(Profile, user_id)
|
||||
if not profile:
|
||||
@router.get("/{user_id}", response_model=UserOut)
|
||||
async def get_profile(user_id: uuid.UUID, db: DbSession) -> UserOut:
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
return public_profile_out(profile)
|
||||
return user_out(user)
|
||||
|
||||
|
||||
@router.get("/{user_id}/stats", response_model=ProfileStatsOut)
|
||||
@@ -114,9 +128,31 @@ async def get_user_stats(
|
||||
viewer: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> ProfileStatsOut:
|
||||
if viewer.id != user_id and viewer.profile.role != "admin":
|
||||
if viewer.id != user_id and viewer.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能查看自己的统计数据")
|
||||
return await _build_stats(user_id, db)
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
return ProfileStatsOut(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
created_at=user.created_at,
|
||||
cloud_count=user.cloud_count,
|
||||
public_cloud_count=user.public_cloud_count,
|
||||
collection_count=user.collection_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/like-stats", response_model=LikeStatsOut)
|
||||
async def get_user_like_stats(
|
||||
user_id: uuid.UUID,
|
||||
viewer: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> LikeStatsOut:
|
||||
if viewer.id != user_id and viewer.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能查看自己的点赞统计数据")
|
||||
return await _build_like_stats(user_id, db)
|
||||
|
||||
|
||||
@router.get("/{user_id}/clouds", response_model=PageOut[CloudOut])
|
||||
@@ -127,7 +163,7 @@ async def get_profile_clouds(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
) -> PageOut[CloudOut]:
|
||||
viewer_role = viewer.profile.role if viewer else None
|
||||
viewer_role = viewer.role if viewer else None
|
||||
viewer_id = viewer.id if viewer else None
|
||||
return await _list_profile_clouds(user_id, db, viewer_role=viewer_role, viewer_id=viewer_id, page=page, page_size=page_size)
|
||||
|
||||
@@ -137,45 +173,46 @@ async def get_profile_clouds(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _build_stats(user_id: uuid.UUID, db: DbSession) -> ProfileStatsOut:
|
||||
result = await db.execute(
|
||||
select(User).options(joinedload(User.profile)).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
async def _build_like_stats(user_id: uuid.UUID, db: DbSession) -> LikeStatsOut:
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
cloud_count = (
|
||||
await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.user_id == user_id))
|
||||
or 0
|
||||
other_likes = func.count(CloudFavorite.id).filter(
|
||||
CloudFavorite.user_id != Cloud.user_id
|
||||
)
|
||||
public_cloud_count = (
|
||||
await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Cloud)
|
||||
.where(
|
||||
Cloud.user_id == user_id,
|
||||
Cloud.status == "approved",
|
||||
Cloud.is_hidden.is_(False),
|
||||
)
|
||||
rows = await db.execute(
|
||||
select(
|
||||
Cloud.id,
|
||||
Cloud.image_path,
|
||||
Cloud.thumbnail_path,
|
||||
Cloud.captured_at,
|
||||
Cloud.created_at,
|
||||
other_likes.label("favorite_count"),
|
||||
)
|
||||
or 0
|
||||
.outerjoin(CloudFavorite, CloudFavorite.cloud_id == Cloud.id)
|
||||
.where(Cloud.user_id == user_id)
|
||||
.group_by(Cloud.id, Cloud.image_path, Cloud.thumbnail_path, Cloud.captured_at, Cloud.created_at)
|
||||
.order_by(other_likes.desc(), Cloud.created_at.desc())
|
||||
)
|
||||
collection_count = (
|
||||
await db.scalar(
|
||||
select(func.count()).select_from(UserCollection).where(UserCollection.user_id == user_id)
|
||||
photos = [
|
||||
LikeStatsPhotoOut(
|
||||
cloud_id=row.id,
|
||||
image_url=media_url(row.image_path) or "",
|
||||
thumbnail_url=media_url(row.thumbnail_path) or "",
|
||||
favorite_count=row.favorite_count,
|
||||
captured_at=row.captured_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
total_likes = sum(p.favorite_count for p in photos)
|
||||
|
||||
return ProfileStatsOut(
|
||||
return LikeStatsOut(
|
||||
user_id=user.id,
|
||||
username=user.profile.username,
|
||||
email=user.email,
|
||||
created_at=user.created_at,
|
||||
cloud_count=cloud_count,
|
||||
public_cloud_count=public_cloud_count,
|
||||
collection_count=collection_count,
|
||||
username=user.username,
|
||||
total_likes=total_likes,
|
||||
photos=photos,
|
||||
)
|
||||
|
||||
|
||||
@@ -188,7 +225,8 @@ async def _list_profile_clouds(
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> PageOut[CloudOut]:
|
||||
if not await db.get(Profile, user_id):
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
can_manage = bool(viewer_id and (viewer_id == user_id or viewer_role == "admin"))
|
||||
@@ -199,7 +237,7 @@ async def _list_profile_clouds(
|
||||
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||
result = await db.execute(
|
||||
select(Cloud)
|
||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
||||
.options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
|
||||
.where(*filters)
|
||||
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
|
||||
Reference in New Issue
Block a user