重构表结构:合并 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:
@@ -0,0 +1,95 @@
|
|||||||
|
"""Merge profiles table into users, add counter columns.
|
||||||
|
|
||||||
|
Revision ID: 20260728_0003
|
||||||
|
Revises: 20260726_0002
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260728_0003"
|
||||||
|
down_revision: str | None = "20260726_0002"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("username", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("role", sa.Text(), server_default="user", nullable=True))
|
||||||
|
op.add_column("users", sa.Column("is_disabled", sa.Boolean(), server_default=sa.text("false"), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("cloud_count", sa.Integer(), server_default="0", nullable=True))
|
||||||
|
op.add_column("users", sa.Column("public_cloud_count", sa.Integer(), server_default="0", nullable=True))
|
||||||
|
op.add_column("users", sa.Column("collection_count", sa.Integer(), server_default="0", nullable=True))
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
UPDATE users SET
|
||||||
|
username = p.username,
|
||||||
|
avatar_path = p.avatar_path,
|
||||||
|
role = p.role,
|
||||||
|
is_disabled = p.is_disabled
|
||||||
|
FROM profiles p
|
||||||
|
WHERE users.id = p.id
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.alter_column("users", "username", nullable=False)
|
||||||
|
op.alter_column("users", "role", nullable=False)
|
||||||
|
op.alter_column("users", "is_disabled", nullable=False)
|
||||||
|
op.alter_column("users", "cloud_count", nullable=False)
|
||||||
|
op.alter_column("users", "public_cloud_count", nullable=False)
|
||||||
|
op.alter_column("users", "collection_count", nullable=False)
|
||||||
|
|
||||||
|
op.create_unique_constraint("uq_users_username", "users", ["username"])
|
||||||
|
op.create_check_constraint("ck_users_username_length", "users", "length(trim(username)) >= 2")
|
||||||
|
op.create_check_constraint("ck_users_role", "users", "role IN ('user', 'admin')")
|
||||||
|
|
||||||
|
op.drop_table("profiles")
|
||||||
|
|
||||||
|
op.add_column("clouds", sa.Column("favorite_count", sa.Integer(), server_default="0", nullable=True))
|
||||||
|
op.execute("""
|
||||||
|
UPDATE clouds SET favorite_count = (
|
||||||
|
SELECT COUNT(*) FROM cloud_favorites WHERE cloud_favorites.cloud_id = clouds.id
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.alter_column("clouds", "favorite_count", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"profiles",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("username", sa.Text(), nullable=False),
|
||||||
|
sa.Column("avatar_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("role", sa.Text(), server_default="user", nullable=False),
|
||||||
|
sa.Column("is_disabled", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"),
|
||||||
|
sa.CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"),
|
||||||
|
sa.ForeignKeyConstraint(["id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("username"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO profiles (id, username, avatar_path, role, is_disabled, created_at, updated_at)
|
||||||
|
SELECT id, username, avatar_path, role, is_disabled, created_at, created_at
|
||||||
|
FROM users
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.drop_constraint("ck_users_role", "users", type_="check")
|
||||||
|
op.drop_constraint("ck_users_username_length", "users", type_="check")
|
||||||
|
op.drop_constraint("uq_users_username", "users", type_="unique")
|
||||||
|
op.drop_column("users", "collection_count")
|
||||||
|
op.drop_column("users", "public_cloud_count")
|
||||||
|
op.drop_column("users", "cloud_count")
|
||||||
|
op.drop_column("users", "is_disabled")
|
||||||
|
op.drop_column("users", "role")
|
||||||
|
op.drop_column("users", "avatar_path")
|
||||||
|
op.drop_column("users", "username")
|
||||||
|
|
||||||
|
op.drop_column("clouds", "favorite_count")
|
||||||
+2
-3
@@ -34,7 +34,6 @@ async def _resolve_user(
|
|||||||
(RefreshSession.id == payload.session_id)
|
(RefreshSession.id == payload.session_id)
|
||||||
& (RefreshSession.user_id == User.id),
|
& (RefreshSession.user_id == User.id),
|
||||||
)
|
)
|
||||||
.options(selectinload(User.profile))
|
|
||||||
.where(
|
.where(
|
||||||
User.id == payload.user_id,
|
User.id == payload.user_id,
|
||||||
RefreshSession.revoked_at.is_(None),
|
RefreshSession.revoked_at.is_(None),
|
||||||
@@ -44,7 +43,7 @@ async def _resolve_user(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效")
|
||||||
if user.profile.is_disabled:
|
if user.is_disabled:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -68,7 +67,7 @@ async def get_optional_user(
|
|||||||
|
|
||||||
|
|
||||||
async def get_admin_user(user: Annotated[User, Depends(get_current_user)]) -> User:
|
async def get_admin_user(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
if user.profile.role != "admin":
|
if user.role != "admin":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
+11
-52
@@ -8,6 +8,7 @@ from sqlalchemy import (
|
|||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
|
Integer,
|
||||||
Numeric,
|
Numeric,
|
||||||
SmallInteger,
|
SmallInteger,
|
||||||
Text,
|
Text,
|
||||||
@@ -32,42 +33,28 @@ class TimestampMixin:
|
|||||||
|
|
||||||
class User(Base, TimestampMixin):
|
class User(Base, TimestampMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("length(trim(username)) >= 2", name="ck_users_username_length"),
|
||||||
|
CheckConstraint("role IN ('user', 'admin')", name="ck_users_role"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
profile: Mapped["Profile"] = relationship(
|
|
||||||
back_populates="user", cascade="all, delete-orphan", uselist=False, lazy="raise"
|
|
||||||
)
|
|
||||||
clouds: Mapped[list["Cloud"]] = relationship(back_populates="user", lazy="raise")
|
|
||||||
|
|
||||||
|
|
||||||
class Profile(Base):
|
|
||||||
__tablename__ = "profiles"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint("length(trim(username)) >= 2", name="ck_profiles_username_length"),
|
|
||||||
CheckConstraint("role IN ('user', 'admin')", name="ck_profiles_role"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
||||||
)
|
|
||||||
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
avatar_path: Mapped[str | None] = mapped_column(Text)
|
avatar_path: Mapped[str | None] = mapped_column(Text)
|
||||||
role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user")
|
role: Mapped[str] = mapped_column(Text, nullable=False, default="user", server_default="user")
|
||||||
is_disabled: Mapped[bool] = mapped_column(
|
is_disabled: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=False, server_default=text("false")
|
Boolean, nullable=False, default=False, server_default=text("false")
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped[User] = relationship(back_populates="profile", lazy="raise")
|
cloud_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
public_cloud_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
collection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
|
clouds: Mapped[list["Cloud"]] = relationship(back_populates="user", lazy="raise")
|
||||||
|
|
||||||
|
|
||||||
class RefreshSession(Base):
|
class RefreshSession(Base):
|
||||||
@@ -163,28 +150,7 @@ class Cloud(Base, TimestampMixin):
|
|||||||
),
|
),
|
||||||
Index("ix_clouds_user_id", "user_id"),
|
Index("ix_clouds_user_id", "user_id"),
|
||||||
Index("ix_clouds_cloud_type_id", "cloud_type_id"),
|
Index("ix_clouds_cloud_type_id", "cloud_type_id"),
|
||||||
Index("ix_clouds_user_captured", "user_id", text("captured_at DESC"), text("created_at DESC")),
|
|
||||||
Index("ix_clouds_status_created", "status", text("created_at DESC")),
|
Index("ix_clouds_status_created", "status", text("created_at DESC")),
|
||||||
Index(
|
|
||||||
"ix_clouds_public_gallery",
|
|
||||||
text("created_at DESC"),
|
|
||||||
text("id DESC"),
|
|
||||||
postgresql_where=text("status = 'approved' AND is_hidden = false"),
|
|
||||||
),
|
|
||||||
Index(
|
|
||||||
"ix_clouds_public_map",
|
|
||||||
"captured_at",
|
|
||||||
postgresql_where=text(
|
|
||||||
"status = 'approved' AND is_hidden = false "
|
|
||||||
"AND latitude IS NOT NULL AND longitude IS NOT NULL"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Index(
|
|
||||||
"ix_clouds_public_type",
|
|
||||||
"cloud_type_id",
|
|
||||||
text("captured_at DESC"),
|
|
||||||
postgresql_where=text("status = 'approved' AND is_hidden = false"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
@@ -206,6 +172,7 @@ class Cloud(Base, TimestampMixin):
|
|||||||
is_hidden: Mapped[bool] = mapped_column(
|
is_hidden: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=False, server_default=text("false")
|
Boolean, nullable=False, default=False, server_default=text("false")
|
||||||
)
|
)
|
||||||
|
favorite_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
||||||
user: Mapped[User] = relationship(back_populates="clouds", lazy="raise")
|
user: Mapped[User] = relationship(back_populates="clouds", lazy="raise")
|
||||||
cloud_type: Mapped[CloudType | None] = relationship(lazy="raise")
|
cloud_type: Mapped[CloudType | None] = relationship(lazy="raise")
|
||||||
@@ -239,19 +206,11 @@ class UserCollection(Base):
|
|||||||
|
|
||||||
|
|
||||||
class CloudFavorite(Base):
|
class CloudFavorite(Base):
|
||||||
"""用户对照片的点赞/收藏记录。"""
|
|
||||||
|
|
||||||
__tablename__ = "cloud_favorites"
|
__tablename__ = "cloud_favorites"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "cloud_id", name="uq_cloud_favorites_user_cloud"),
|
UniqueConstraint("user_id", "cloud_id", name="uq_cloud_favorites_user_cloud"),
|
||||||
Index("ix_cloud_favorites_user_id", "user_id"),
|
Index("ix_cloud_favorites_user_id", "user_id"),
|
||||||
Index("ix_cloud_favorites_cloud_id", "cloud_id"),
|
Index("ix_cloud_favorites_cloud_id", "cloud_id"),
|
||||||
Index(
|
|
||||||
"ix_cloud_favorites_user_created",
|
|
||||||
"user_id",
|
|
||||||
text("created_at DESC"),
|
|
||||||
text("id DESC"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
|||||||
+86
-16
@@ -7,10 +7,10 @@ from zoneinfo import ZoneInfo
|
|||||||
from fastapi import APIRouter, HTTPException, Query, status
|
from fastapi import APIRouter, HTTPException, Query, status
|
||||||
from sqlalchemy import case, func, select, update
|
from sqlalchemy import case, func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.deps import AdminUser, DbSession
|
from app.deps import AdminUser, DbSession
|
||||||
from app.models import Cloud, Profile, User
|
from app.models import Cloud, User
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
AdminCloudStatusIn,
|
AdminCloudStatusIn,
|
||||||
AdminCloudVisibilityIn,
|
AdminCloudVisibilityIn,
|
||||||
@@ -25,7 +25,7 @@ from app.schemas import (
|
|||||||
PageOut,
|
PageOut,
|
||||||
)
|
)
|
||||||
from app.security import hash_password, utc_now
|
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
|
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
|
total = await db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User)
|
select(User)
|
||||||
.options(selectinload(User.profile))
|
|
||||||
.order_by(User.created_at.desc(), User.id.desc())
|
.order_by(User.created_at.desc(), User.id.desc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
.limit(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(
|
return PageOut(
|
||||||
items=items,
|
items=items,
|
||||||
page=page,
|
page=page,
|
||||||
@@ -97,8 +96,9 @@ async def create_user(
|
|||||||
email=email,
|
email=email,
|
||||||
password_hash=hash_password(payload.password),
|
password_hash=hash_password(payload.password),
|
||||||
email_verified_at=utc_now(),
|
email_verified_at=utc_now(),
|
||||||
|
username=payload.username,
|
||||||
|
role=payload.role,
|
||||||
)
|
)
|
||||||
user.profile = Profile(username=payload.username, role=payload.role)
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
try:
|
try:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -108,8 +108,8 @@ async def create_user(
|
|||||||
if "email" in message:
|
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
|
||||||
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"])
|
await db.refresh(user)
|
||||||
return AdminUserOut(user=user_out(user), profile=profile_out(user.profile))
|
return AdminUserOut(user=user_out(user))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
||||||
@@ -119,10 +119,7 @@ async def update_user(
|
|||||||
admin: AdminUser,
|
admin: AdminUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> AdminUserOut:
|
) -> AdminUserOut:
|
||||||
result = await db.execute(
|
user = await db.get(User, user_id)
|
||||||
select(User).options(selectinload(User.profile)).where(User.id == user_id)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
if user.id == admin.id and (
|
if user.id == admin.id and (
|
||||||
@@ -130,11 +127,11 @@ async def update_user(
|
|||||||
):
|
):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能禁用自己或移除自己的管理员权限")
|
||||||
if payload.role is not None:
|
if payload.role is not None:
|
||||||
user.profile.role = payload.role
|
user.role = payload.role
|
||||||
if payload.is_disabled is not None:
|
if payload.is_disabled is not None:
|
||||||
user.profile.is_disabled = payload.is_disabled
|
user.is_disabled = payload.is_disabled
|
||||||
await db.commit()
|
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])
|
@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
|
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Cloud)
|
select(Cloud)
|
||||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
.options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
|
||||||
.where(*filters)
|
.where(*filters)
|
||||||
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
|
.order_by(Cloud.created_at.desc(), Cloud.id.desc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
@@ -182,6 +179,37 @@ async def update_cloud_status(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> BatchUpdateOut:
|
) -> BatchUpdateOut:
|
||||||
await _require_all_cloud_ids(db, payload.ids)
|
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(
|
await db.execute(
|
||||||
update(Cloud)
|
update(Cloud)
|
||||||
.where(Cloud.id.in_(payload.ids))
|
.where(Cloud.id.in_(payload.ids))
|
||||||
@@ -198,6 +226,37 @@ async def update_cloud_visibility(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> BatchUpdateOut:
|
) -> BatchUpdateOut:
|
||||||
await _require_all_cloud_ids(db, payload.ids)
|
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(
|
await db.execute(
|
||||||
update(Cloud)
|
update(Cloud)
|
||||||
.where(Cloud.id.in_(payload.ids))
|
.where(Cloud.id.in_(payload.ids))
|
||||||
@@ -218,8 +277,19 @@ async def delete_admin_clouds(
|
|||||||
if len(clouds) != len(payload.ids):
|
if len(clouds) != len(payload.ids):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在")
|
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)]
|
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:
|
for cloud in clouds:
|
||||||
|
user_ids[cloud.user_id] = user_ids.get(cloud.user_id, 0) + 1
|
||||||
await db.delete(cloud)
|
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 db.commit()
|
||||||
await delete_files(paths)
|
await delete_files(paths)
|
||||||
return DeleteResultOut(deleted=len(clouds))
|
return DeleteResultOut(deleted=len(clouds))
|
||||||
|
|||||||
+53
-42
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.deps import CurrentUser, DbSession
|
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 (
|
from app.schemas import (
|
||||||
AuthOut,
|
AuthOut,
|
||||||
ChangePasswordIn,
|
ChangePasswordIn,
|
||||||
@@ -30,7 +30,7 @@ from app.security import (
|
|||||||
utc_now,
|
utc_now,
|
||||||
verify_password,
|
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
|
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:
|
async def _create_email_token(db: DbSession, user: User, purpose: str, expires_delta: timedelta) -> str:
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
await db.execute(
|
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)
|
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(payload: RegisterIn, db: DbSession) -> MessageOut:
|
async def register(payload: RegisterIn, db: DbSession) -> MessageOut:
|
||||||
email = str(payload.email).strip().lower()
|
email = str(payload.email).strip().lower()
|
||||||
user = User(email=email, password_hash=hash_password(payload.password))
|
user = User(email=email, password_hash=hash_password(payload.password), username=payload.username)
|
||||||
user.profile = Profile(username=payload.username)
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
try:
|
try:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -194,9 +171,7 @@ async def confirm_email(payload: TokenIn, db: DbSession) -> MessageOut:
|
|||||||
@router.post("/login", response_model=AuthOut)
|
@router.post("/login", response_model=AuthOut)
|
||||||
async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut:
|
async def login(payload: LoginIn, request: Request, response: Response, db: DbSession) -> AuthOut:
|
||||||
email = str(payload.email).strip().lower()
|
email = str(payload.email).strip().lower()
|
||||||
result = await db.execute(
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
select(User).options(selectinload(User.profile)).where(User.email == email)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH
|
encoded_hash = user.password_hash if user else DUMMY_PASSWORD_HASH
|
||||||
password_valid = verify_password(payload.password, encoded_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="邮箱或密码错误")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||||
if user.email_verified_at is None:
|
if user.email_verified_at is None:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="邮箱尚未确认")
|
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="账号已被禁用")
|
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)
|
db.add(session)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
_set_refresh_cookie(response, refresh_token)
|
_set_refresh_cookie(response, token)
|
||||||
return _auth_response(user, session)
|
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)
|
@router.post("/refresh", response_model=AuthOut)
|
||||||
@@ -222,7 +209,7 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu
|
|||||||
now = utc_now()
|
now = utc_now()
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RefreshSession)
|
select(RefreshSession)
|
||||||
.options(selectinload(RefreshSession.user).selectinload(User.profile))
|
.options(selectinload(RefreshSession.user))
|
||||||
.where(RefreshSession.token_hash == hash_token(raw_token))
|
.where(RefreshSession.token_hash == hash_token(raw_token))
|
||||||
.with_for_update()
|
.with_for_update()
|
||||||
)
|
)
|
||||||
@@ -235,20 +222,32 @@ async def refresh(request: Request, response: Response, db: DbSession) -> AuthOu
|
|||||||
_clear_refresh_cookie(response)
|
_clear_refresh_cookie(response)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌无效或已过期")
|
||||||
user = old_session.user
|
user = old_session.user
|
||||||
if user.profile.is_disabled:
|
if user.is_disabled:
|
||||||
old_session.revoked_at = now
|
old_session.revoked_at = now
|
||||||
await db.commit()
|
await db.commit()
|
||||||
_clear_refresh_cookie(response)
|
_clear_refresh_cookie(response)
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用")
|
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)
|
db.add(new_session)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
old_session.revoked_at = now
|
old_session.revoked_at = now
|
||||||
old_session.replaced_by_id = new_session.id
|
old_session.replaced_by_id = new_session.id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
_set_refresh_cookie(response, new_token)
|
_set_refresh_cookie(response, token)
|
||||||
return _auth_response(user, new_session)
|
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)
|
@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)
|
@router.get("/me", response_model=MeOut)
|
||||||
async def me(user: CurrentUser) -> 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)
|
@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))
|
.where(RefreshSession.user_id == user.id, RefreshSession.revoked_at.is_(None))
|
||||||
.values(revoked_at=now)
|
.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)
|
db.add(new_session)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
_set_refresh_cookie(response, refresh_token)
|
_set_refresh_cookie(response, token)
|
||||||
return _auth_response(user, new_session)
|
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
|
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Cloud)
|
select(Cloud)
|
||||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
.options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
|
||||||
.where(*filters)
|
.where(*filters)
|
||||||
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
|
|||||||
+73
-11
@@ -5,13 +5,13 @@ from decimal import Decimal, ROUND_HALF_UP
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
|
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.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.deps import CurrentUser, DbSession, OptionalUser
|
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 (
|
from app.schemas import (
|
||||||
BadgeOut,
|
BadgeOut,
|
||||||
BatchIdsIn,
|
BatchIdsIn,
|
||||||
@@ -30,7 +30,7 @@ router = APIRouter(prefix="/clouds", tags=["云图"])
|
|||||||
|
|
||||||
def _cloud_options():
|
def _cloud_options():
|
||||||
return (
|
return (
|
||||||
joinedload(Cloud.user).joinedload(User.profile),
|
joinedload(Cloud.user),
|
||||||
joinedload(Cloud.cloud_type),
|
joinedload(Cloud.cloud_type),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,9 +93,9 @@ async def list_gallery_clouds(
|
|||||||
username = term[1:].strip()
|
username = term[1:].strip()
|
||||||
if not username:
|
if not username:
|
||||||
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
return PageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||||
data_query = data_query.join(Cloud.user).join(User.profile)
|
data_query = data_query.join(Cloud.user)
|
||||||
count_query = count_query.join(Cloud.user).join(User.profile)
|
count_query = count_query.join(Cloud.user)
|
||||||
filters.append(Profile.username.ilike(f"%{username}%"))
|
filters.append(User.username.ilike(f"%{username}%"))
|
||||||
elif term:
|
elif term:
|
||||||
data_query = data_query.outerjoin(Cloud.cloud_type)
|
data_query = data_query.outerjoin(Cloud.cloud_type)
|
||||||
count_query = count_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)
|
cloud = await _get_cloud(db, cloud_id)
|
||||||
if not cloud:
|
if not cloud:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
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):
|
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
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)
|
@router.post("", response_model=CloudCreateOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -207,6 +219,13 @@ async def create_cloud(
|
|||||||
unlocked_badge: BadgeOut | None = None
|
unlocked_badge: BadgeOut | None = None
|
||||||
try:
|
try:
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == user.id)
|
||||||
|
.values(cloud_count=User.cloud_count + 1)
|
||||||
|
)
|
||||||
|
|
||||||
if cloud_type:
|
if cloud_type:
|
||||||
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
insert_factory = pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
||||||
insert_result = await db.execute(
|
insert_result = await db.execute(
|
||||||
@@ -223,6 +242,11 @@ async def create_cloud(
|
|||||||
)
|
)
|
||||||
unlocked_at = insert_result.scalar_one_or_none()
|
unlocked_at = insert_result.scalar_one_or_none()
|
||||||
if unlocked_at:
|
if unlocked_at:
|
||||||
|
await db.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == user.id)
|
||||||
|
.values(collection_count=User.collection_count + 1)
|
||||||
|
)
|
||||||
unlocked_badge = BadgeOut(
|
unlocked_badge = BadgeOut(
|
||||||
cloud_type_id=cloud_type.id,
|
cloud_type_id=cloud_type.id,
|
||||||
cloud_name=cloud_type.name,
|
cloud_name=cloud_type.name,
|
||||||
@@ -252,9 +276,10 @@ async def update_cloud(
|
|||||||
cloud = await _get_cloud(db, cloud_id)
|
cloud = await _get_cloud(db, cloud_id)
|
||||||
if not cloud:
|
if not cloud:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
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="不能修改其他用户的图片")
|
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)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
final_type_id = changes.get("cloud_type_id", cloud.cloud_type_id)
|
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))
|
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"])
|
cloud.captured_at = _normalize_datetime(changes["captured_at"])
|
||||||
if "is_hidden" in changes:
|
if "is_hidden" in changes:
|
||||||
cloud.is_hidden = changes["is_hidden"]
|
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)
|
updated = await _get_cloud(db, cloud.id)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
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)
|
cloud = await _get_cloud(db, cloud_id)
|
||||||
if not cloud:
|
if not cloud:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
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="不能删除其他用户的图片")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能删除其他用户的图片")
|
||||||
paths = [cloud.image_path, cloud.thumbnail_path]
|
paths = [cloud.image_path, cloud.thumbnail_path]
|
||||||
|
|
||||||
|
was_public = cloud.status == "approved" and not cloud.is_hidden
|
||||||
await db.delete(cloud)
|
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 db.commit()
|
||||||
await delete_files(paths)
|
await delete_files(paths)
|
||||||
return DeleteResultOut(deleted=1)
|
return DeleteResultOut(deleted=1)
|
||||||
@@ -318,9 +368,21 @@ async def batch_delete_clouds(
|
|||||||
clouds = result.scalars().all()
|
clouds = result.scalars().all()
|
||||||
if len(clouds) != len(payload.ids):
|
if len(clouds) != len(payload.ids):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部分图片不存在或不属于当前用户")
|
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)]
|
paths = [path for cloud in clouds for path in (cloud.image_path, cloud.thumbnail_path)]
|
||||||
for cloud in clouds:
|
for cloud in clouds:
|
||||||
await db.delete(cloud)
|
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 db.commit()
|
||||||
await delete_files(paths)
|
await delete_files(paths)
|
||||||
return DeleteResultOut(deleted=len(clouds))
|
return DeleteResultOut(deleted=len(clouds))
|
||||||
|
|||||||
+26
-22
@@ -2,7 +2,7 @@ import math
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, status
|
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.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
@@ -18,7 +18,7 @@ router = APIRouter(tags=["点赞收藏"])
|
|||||||
|
|
||||||
def _cloud_options():
|
def _cloud_options():
|
||||||
return (
|
return (
|
||||||
joinedload(Cloud.user).joinedload(User.profile),
|
joinedload(Cloud.user),
|
||||||
joinedload(Cloud.cloud_type),
|
joinedload(Cloud.cloud_type),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,23 +35,12 @@ async def _get_visible_cloud(
|
|||||||
if not cloud:
|
if not cloud:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
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):
|
if not can_manage and (cloud.status != "approved" or cloud.is_hidden):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
||||||
return cloud
|
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:
|
async def _is_favorited(db: DbSession, user_id: uuid.UUID, cloud_id: uuid.UUID) -> bool:
|
||||||
favorite_id = await db.scalar(
|
favorite_id = await db.scalar(
|
||||||
select(CloudFavorite.id).where(
|
select(CloudFavorite.id).where(
|
||||||
@@ -71,11 +60,12 @@ async def _favorite_out(
|
|||||||
) -> FavoriteOut:
|
) -> FavoriteOut:
|
||||||
if favorited is None:
|
if favorited is None:
|
||||||
favorited = await _is_favorited(db, user_id, cloud_id)
|
favorited = await _is_favorited(db, user_id, cloud_id)
|
||||||
|
cloud = await db.get(Cloud, cloud_id)
|
||||||
return FavoriteOut(
|
return FavoriteOut(
|
||||||
cloud_id=cloud_id,
|
cloud_id=cloud_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
favorited=favorited,
|
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;默认当前登录用户。仅本人或管理员可查他人。",
|
description="要查询的用户 ID;默认当前登录用户。仅本人或管理员可查他人。",
|
||||||
),
|
),
|
||||||
) -> FavoriteOut:
|
) -> FavoriteOut:
|
||||||
"""查询某用户对某张照片的点赞/收藏状态。"""
|
|
||||||
await _get_visible_cloud(db, cloud_id, user)
|
await _get_visible_cloud(db, cloud_id, user)
|
||||||
|
|
||||||
target_user_id = user_id or user.id
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="只能查询自己的喜欢状态",
|
detail="只能查询自己的喜欢状态",
|
||||||
@@ -109,27 +98,43 @@ async def set_cloud_favorite(
|
|||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> FavoriteOut:
|
) -> FavoriteOut:
|
||||||
"""点赞/收藏照片,或取消。"""
|
|
||||||
await _get_visible_cloud(db, cloud_id, user)
|
await _get_visible_cloud(db, cloud_id, user)
|
||||||
|
|
||||||
if payload.favorited:
|
if payload.favorited:
|
||||||
insert_factory = (
|
insert_factory = (
|
||||||
pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
pg_insert if db.bind and db.bind.dialect.name == "postgresql" else sqlite_insert
|
||||||
)
|
)
|
||||||
await db.execute(
|
result = await db.execute(
|
||||||
insert_factory(CloudFavorite)
|
insert_factory(CloudFavorite)
|
||||||
.values(user_id=user.id, cloud_id=cloud_id)
|
.values(user_id=user.id, cloud_id=cloud_id)
|
||||||
.on_conflict_do_nothing(
|
.on_conflict_do_nothing(
|
||||||
index_elements=[CloudFavorite.user_id, CloudFavorite.cloud_id]
|
index_elements=[CloudFavorite.user_id, CloudFavorite.cloud_id]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
if result.rowcount and result.rowcount > 0:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
update(Cloud)
|
||||||
|
.where(Cloud.id == cloud_id)
|
||||||
|
.values(favorite_count=Cloud.favorite_count + 1)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = await db.execute(
|
||||||
delete(CloudFavorite).where(
|
delete(CloudFavorite).where(
|
||||||
CloudFavorite.user_id == user.id,
|
CloudFavorite.user_id == user.id,
|
||||||
CloudFavorite.cloud_id == cloud_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()
|
await db.commit()
|
||||||
return await _favorite_out(
|
return await _favorite_out(
|
||||||
@@ -147,7 +152,6 @@ async def list_my_favorites(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=100),
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
) -> PageOut[CloudOut]:
|
) -> PageOut[CloudOut]:
|
||||||
"""当前用户点赞收藏的照片列表(按收藏时间倒序)。"""
|
|
||||||
total = (
|
total = (
|
||||||
await db.scalar(
|
await db.scalar(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
@@ -166,7 +170,7 @@ async def list_my_favorites(
|
|||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
items = [
|
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()
|
for item in result.scalars().unique().all()
|
||||||
]
|
]
|
||||||
return PageOut(
|
return PageOut(
|
||||||
|
|||||||
+106
-68
@@ -3,21 +3,22 @@ import uuid
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
|
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.exc import IntegrityError
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.deps import CurrentUser, DbSession, OptionalUser
|
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 (
|
from app.schemas import (
|
||||||
CloudOut,
|
CloudOut,
|
||||||
|
LikeStatsOut,
|
||||||
|
LikeStatsPhotoOut,
|
||||||
PageOut,
|
PageOut,
|
||||||
ProfileOut,
|
|
||||||
ProfileStatsOut,
|
ProfileStatsOut,
|
||||||
ProfileUpdateIn,
|
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
|
from app.services.storage import delete_files, save_avatar
|
||||||
|
|
||||||
|
|
||||||
@@ -29,60 +30,73 @@ router = APIRouter(prefix="/profiles", tags=["用户资料"])
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=PublicProfileOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
async def get_my_profile(user: CurrentUser, db: DbSession) -> PublicProfileOut:
|
async def get_my_profile(user: CurrentUser) -> UserOut:
|
||||||
return public_profile_out(user.profile)
|
return user_out(user)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me", response_model=ProfileOut)
|
@router.patch("/me", response_model=UserOut)
|
||||||
async def update_my_profile(
|
async def update_my_profile(
|
||||||
payload: ProfileUpdateIn,
|
payload: ProfileUpdateIn,
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> ProfileOut:
|
) -> UserOut:
|
||||||
user.profile.username = payload.username
|
user.username = payload.username
|
||||||
try:
|
try:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="这个昵称已经被使用") from exc
|
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(
|
async def upload_avatar(
|
||||||
image: Annotated[UploadFile, File()],
|
image: Annotated[UploadFile, File()],
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> ProfileOut:
|
) -> UserOut:
|
||||||
if user.profile.avatar_path:
|
if user.avatar_path:
|
||||||
old_path = user.profile.avatar_path
|
old_path = user.avatar_path
|
||||||
user.profile.avatar_path = None
|
user.avatar_path = None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await delete_files([old_path])
|
await delete_files([old_path])
|
||||||
avatar_path = await save_avatar(image, user.id)
|
avatar_path = await save_avatar(image, user.id)
|
||||||
user.profile.avatar_path = avatar_path
|
user.avatar_path = avatar_path
|
||||||
await db.commit()
|
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(
|
async def delete_avatar(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> ProfileOut:
|
) -> UserOut:
|
||||||
if not user.profile.avatar_path:
|
if not user.avatar_path:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有头像")
|
||||||
old_path = user.profile.avatar_path
|
old_path = user.avatar_path
|
||||||
user.profile.avatar_path = None
|
user.avatar_path = None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await delete_files([old_path])
|
await delete_files([old_path])
|
||||||
return profile_out(user.profile)
|
return user_out(user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/stats", response_model=ProfileStatsOut)
|
@router.get("/me/stats", response_model=ProfileStatsOut)
|
||||||
async def get_my_stats(user: CurrentUser, db: DbSession) -> ProfileStatsOut:
|
async def get_my_stats(user: CurrentUser) -> ProfileStatsOut:
|
||||||
return await _build_stats(user.id, db)
|
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])
|
@router.get("/me/clouds", response_model=PageOut[CloudOut])
|
||||||
@@ -92,7 +106,7 @@ async def get_my_clouds(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=100),
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
) -> PageOut[CloudOut]:
|
) -> 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)
|
@router.get("/{user_id}", response_model=UserOut)
|
||||||
async def get_profile(user_id: uuid.UUID, db: DbSession) -> PublicProfileOut:
|
async def get_profile(user_id: uuid.UUID, db: DbSession) -> UserOut:
|
||||||
profile = await db.get(Profile, user_id)
|
user = await db.get(User, user_id)
|
||||||
if not profile:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
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)
|
@router.get("/{user_id}/stats", response_model=ProfileStatsOut)
|
||||||
@@ -114,9 +128,31 @@ async def get_user_stats(
|
|||||||
viewer: CurrentUser,
|
viewer: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> ProfileStatsOut:
|
) -> 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="只能查看自己的统计数据")
|
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])
|
@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: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=100),
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
) -> PageOut[CloudOut]:
|
) -> 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
|
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)
|
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:
|
async def _build_like_stats(user_id: uuid.UUID, db: DbSession) -> LikeStatsOut:
|
||||||
result = await db.execute(
|
user = await db.get(User, user_id)
|
||||||
select(User).options(joinedload(User.profile)).where(User.id == user_id)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
cloud_count = (
|
other_likes = func.count(CloudFavorite.id).filter(
|
||||||
await db.scalar(select(func.count()).select_from(Cloud).where(Cloud.user_id == user_id))
|
CloudFavorite.user_id != Cloud.user_id
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
public_cloud_count = (
|
rows = await db.execute(
|
||||||
await db.scalar(
|
select(
|
||||||
select(func.count())
|
Cloud.id,
|
||||||
.select_from(Cloud)
|
Cloud.image_path,
|
||||||
.where(
|
Cloud.thumbnail_path,
|
||||||
Cloud.user_id == user_id,
|
Cloud.captured_at,
|
||||||
Cloud.status == "approved",
|
Cloud.created_at,
|
||||||
Cloud.is_hidden.is_(False),
|
other_likes.label("favorite_count"),
|
||||||
)
|
)
|
||||||
|
.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())
|
||||||
)
|
)
|
||||||
or 0
|
photos = [
|
||||||
)
|
LikeStatsPhotoOut(
|
||||||
collection_count = (
|
cloud_id=row.id,
|
||||||
await db.scalar(
|
image_url=media_url(row.image_path) or "",
|
||||||
select(func.count()).select_from(UserCollection).where(UserCollection.user_id == user_id)
|
thumbnail_url=media_url(row.thumbnail_path) or "",
|
||||||
)
|
favorite_count=row.favorite_count,
|
||||||
or 0
|
captured_at=row.captured_at,
|
||||||
|
created_at=row.created_at,
|
||||||
)
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
total_likes = sum(p.favorite_count for p in photos)
|
||||||
|
|
||||||
return ProfileStatsOut(
|
return LikeStatsOut(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
username=user.profile.username,
|
username=user.username,
|
||||||
email=user.email,
|
total_likes=total_likes,
|
||||||
created_at=user.created_at,
|
photos=photos,
|
||||||
cloud_count=cloud_count,
|
|
||||||
public_cloud_count=public_cloud_count,
|
|
||||||
collection_count=collection_count,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +225,8 @@ async def _list_profile_clouds(
|
|||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
) -> PageOut[CloudOut]:
|
) -> 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="用户不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
can_manage = bool(viewer_id and (viewer_id == user_id or viewer_role == "admin"))
|
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
|
total = await db.scalar(select(func.count()).select_from(Cloud).where(*filters)) or 0
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Cloud)
|
select(Cloud)
|
||||||
.options(joinedload(Cloud.user).joinedload(User.profile), joinedload(Cloud.cloud_type))
|
.options(joinedload(Cloud.user), joinedload(Cloud.cloud_type))
|
||||||
.where(*filters)
|
.where(*filters)
|
||||||
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
.order_by(Cloud.captured_at.desc().nulls_last(), Cloud.created_at.desc(), Cloud.id.desc())
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
|
|||||||
+35
-33
@@ -49,30 +49,19 @@ class ChangePasswordIn(BaseModel):
|
|||||||
password: str = Field(min_length=8, max_length=128)
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class ProfileOut(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
username: str
|
|
||||||
avatar_url: str | None = None
|
|
||||||
role: Role
|
|
||||||
is_disabled: bool
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class PublicProfileOut(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
username: str
|
|
||||||
avatar_url: str | None = None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
|
username: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
role: Role
|
||||||
|
is_disabled: bool
|
||||||
|
cloud_count: int
|
||||||
|
public_cloud_count: int
|
||||||
|
collection_count: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -81,12 +70,10 @@ class AuthOut(BaseModel):
|
|||||||
token_type: Literal["bearer"] = "bearer"
|
token_type: Literal["bearer"] = "bearer"
|
||||||
expires_in: int
|
expires_in: int
|
||||||
user: UserOut
|
user: UserOut
|
||||||
profile: ProfileOut
|
|
||||||
|
|
||||||
|
|
||||||
class MeOut(BaseModel):
|
class MeOut(BaseModel):
|
||||||
user: UserOut
|
user: UserOut
|
||||||
profile: ProfileOut
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileUpdateIn(BaseModel):
|
class ProfileUpdateIn(BaseModel):
|
||||||
@@ -98,6 +85,16 @@ class ProfileUpdateIn(BaseModel):
|
|||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileStatsOut(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
created_at: datetime
|
||||||
|
cloud_count: int
|
||||||
|
public_cloud_count: int
|
||||||
|
collection_count: int
|
||||||
|
|
||||||
|
|
||||||
class CloudTypeSummary(BaseModel):
|
class CloudTypeSummary(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
@@ -138,6 +135,8 @@ class CloudOut(BaseModel):
|
|||||||
cloud_type_name: str
|
cloud_type_name: str
|
||||||
cloud_type_rarity: Rarity
|
cloud_type_rarity: Rarity
|
||||||
username: str
|
username: str
|
||||||
|
favorite_count: int = 0
|
||||||
|
is_favorited: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CloudUpdateIn(BaseModel):
|
class CloudUpdateIn(BaseModel):
|
||||||
@@ -228,7 +227,6 @@ class AdminStatsOut(BaseModel):
|
|||||||
|
|
||||||
class AdminUserOut(BaseModel):
|
class AdminUserOut(BaseModel):
|
||||||
user: UserOut
|
user: UserOut
|
||||||
profile: ProfileOut
|
|
||||||
|
|
||||||
|
|
||||||
class AdminUserUpdateIn(BaseModel):
|
class AdminUserUpdateIn(BaseModel):
|
||||||
@@ -269,19 +267,7 @@ class BatchUpdateOut(BaseModel):
|
|||||||
updated: int
|
updated: int
|
||||||
|
|
||||||
|
|
||||||
class ProfileStatsOut(BaseModel):
|
|
||||||
user_id: uuid.UUID
|
|
||||||
username: str
|
|
||||||
email: str
|
|
||||||
created_at: datetime
|
|
||||||
cloud_count: int
|
|
||||||
public_cloud_count: int
|
|
||||||
collection_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class FavoriteIn(BaseModel):
|
class FavoriteIn(BaseModel):
|
||||||
"""设置或取消对照片的点赞收藏。"""
|
|
||||||
|
|
||||||
favorited: bool
|
favorited: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -290,3 +276,19 @@ class FavoriteOut(BaseModel):
|
|||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
favorited: bool
|
favorited: bool
|
||||||
favorite_count: int
|
favorite_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class LikeStatsPhotoOut(BaseModel):
|
||||||
|
cloud_id: uuid.UUID
|
||||||
|
image_url: str
|
||||||
|
thumbnail_url: str
|
||||||
|
favorite_count: int
|
||||||
|
captured_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LikeStatsOut(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
total_likes: int
|
||||||
|
photos: list[LikeStatsPhotoOut]
|
||||||
|
|||||||
+6
-5
@@ -5,7 +5,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import CloudType, Profile, User
|
from app.models import CloudType, User
|
||||||
from app.security import hash_password, utc_now
|
from app.security import hash_password, utc_now
|
||||||
|
|
||||||
|
|
||||||
@@ -36,20 +36,21 @@ async def seed() -> None:
|
|||||||
if settings.admin_email and settings.admin_password:
|
if settings.admin_email and settings.admin_password:
|
||||||
email = settings.admin_email.strip().lower()
|
email = settings.admin_email.strip().lower()
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).options(selectinload(User.profile)).where(User.email == email)
|
select(User).where(User.email == email)
|
||||||
)
|
)
|
||||||
admin = result.scalar_one_or_none()
|
admin = result.scalar_one_or_none()
|
||||||
if admin:
|
if admin:
|
||||||
admin.profile.role = "admin"
|
admin.role = "admin"
|
||||||
admin.profile.is_disabled = False
|
admin.is_disabled = False
|
||||||
admin.email_verified_at = admin.email_verified_at or utc_now()
|
admin.email_verified_at = admin.email_verified_at or utc_now()
|
||||||
else:
|
else:
|
||||||
admin = User(
|
admin = User(
|
||||||
email=email,
|
email=email,
|
||||||
password_hash=hash_password(settings.admin_password),
|
password_hash=hash_password(settings.admin_password),
|
||||||
email_verified_at=utc_now(),
|
email_verified_at=utc_now(),
|
||||||
|
username=settings.admin_username,
|
||||||
|
role="admin",
|
||||||
)
|
)
|
||||||
admin.profile = Profile(username=settings.admin_username, role="admin")
|
|
||||||
db.add(admin)
|
db.add(admin)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
+20
-27
@@ -1,12 +1,10 @@
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Cloud, CloudType, Profile, User
|
from app.models import Cloud, CloudType, User
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
CloudOut,
|
CloudOut,
|
||||||
CloudOwnerOut,
|
CloudOwnerOut,
|
||||||
CloudTypeOut,
|
CloudTypeOut,
|
||||||
CloudTypeSummary,
|
CloudTypeSummary,
|
||||||
ProfileOut,
|
|
||||||
PublicProfileOut,
|
|
||||||
UserOut,
|
UserOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,31 +15,18 @@ def media_url(path: str | None) -> str | None:
|
|||||||
return f"{settings.media_base_url}/{path.lstrip('/')}"
|
return f"{settings.media_base_url}/{path.lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
def profile_out(profile: Profile) -> ProfileOut:
|
|
||||||
return ProfileOut(
|
|
||||||
id=profile.id,
|
|
||||||
username=profile.username,
|
|
||||||
avatar_url=media_url(profile.avatar_path),
|
|
||||||
role=profile.role,
|
|
||||||
is_disabled=profile.is_disabled,
|
|
||||||
created_at=profile.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def public_profile_out(profile: Profile) -> PublicProfileOut:
|
|
||||||
return PublicProfileOut(
|
|
||||||
id=profile.id,
|
|
||||||
username=profile.username,
|
|
||||||
avatar_url=media_url(profile.avatar_path),
|
|
||||||
created_at=profile.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def user_out(user: User) -> UserOut:
|
def user_out(user: User) -> UserOut:
|
||||||
return UserOut(
|
return UserOut(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
email_verified=user.email_verified_at is not None,
|
email_verified=user.email_verified_at is not None,
|
||||||
|
username=user.username,
|
||||||
|
avatar_url=media_url(user.avatar_path),
|
||||||
|
role=user.role,
|
||||||
|
is_disabled=user.is_disabled,
|
||||||
|
cloud_count=user.cloud_count,
|
||||||
|
public_cloud_count=user.public_cloud_count,
|
||||||
|
collection_count=user.collection_count,
|
||||||
created_at=user.created_at,
|
created_at=user.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,9 +53,15 @@ def cloud_type_out(cloud_type: CloudType) -> CloudTypeOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def cloud_out(cloud: Cloud, *, include_private: bool = True) -> CloudOut:
|
def cloud_out(
|
||||||
|
cloud: Cloud,
|
||||||
|
*,
|
||||||
|
include_private: bool = True,
|
||||||
|
favorite_count: int | None = None,
|
||||||
|
is_favorited: bool = False,
|
||||||
|
) -> CloudOut:
|
||||||
cloud_type = cloud.cloud_type
|
cloud_type = cloud.cloud_type
|
||||||
profile = cloud.user.profile
|
owner = cloud.user
|
||||||
type_name = cloud_type.name if cloud_type else (cloud.custom_cloud_type or "未知")
|
type_name = cloud_type.name if cloud_type else (cloud.custom_cloud_type or "未知")
|
||||||
rarity = cloud_type.rarity if cloud_type else "common"
|
rarity = cloud_type.rarity if cloud_type else "common"
|
||||||
return CloudOut(
|
return CloudOut(
|
||||||
@@ -90,8 +81,10 @@ def cloud_out(cloud: Cloud, *, include_private: bool = True) -> CloudOut:
|
|||||||
created_at=cloud.created_at,
|
created_at=cloud.created_at,
|
||||||
updated_at=cloud.updated_at,
|
updated_at=cloud.updated_at,
|
||||||
cloud_type=cloud_type_summary(cloud_type) if cloud_type else None,
|
cloud_type=cloud_type_summary(cloud_type) if cloud_type else None,
|
||||||
owner=CloudOwnerOut(id=profile.id, username=profile.username),
|
owner=CloudOwnerOut(id=owner.id, username=owner.username),
|
||||||
cloud_type_name=type_name,
|
cloud_type_name=type_name,
|
||||||
cloud_type_rarity=rarity,
|
cloud_type_rarity=rarity,
|
||||||
username=profile.username,
|
username=owner.username,
|
||||||
|
favorite_count=favorite_count if favorite_count is not None else 0,
|
||||||
|
is_favorited=is_favorited,
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -71,7 +71,7 @@ def test_create_user(client: TestClient) -> None:
|
|||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["user"]["email_verified"] is True
|
assert body["user"]["email_verified"] is True
|
||||||
assert body["profile"]["role"] == "user"
|
assert body["user"]["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
def test_create_user_with_role(client: TestClient) -> None:
|
def test_create_user_with_role(client: TestClient) -> None:
|
||||||
@@ -87,7 +87,7 @@ def test_create_user_with_role(client: TestClient) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
assert r.json()["profile"]["role"] == "admin"
|
assert r.json()["user"]["role"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
def test_create_user_duplicate_email(client: TestClient) -> None:
|
def test_create_user_duplicate_email(client: TestClient) -> None:
|
||||||
@@ -141,7 +141,7 @@ def test_update_user_role(client: TestClient, monkeypatch) -> None:
|
|||||||
json={"role": "admin"},
|
json={"role": "admin"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["profile"]["role"] == "admin"
|
assert r.json()["user"]["role"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
def test_update_user_disable(client: TestClient, monkeypatch) -> None:
|
def test_update_user_disable(client: TestClient, monkeypatch) -> None:
|
||||||
@@ -157,7 +157,7 @@ def test_update_user_disable(client: TestClient, monkeypatch) -> None:
|
|||||||
json={"is_disabled": True},
|
json={"is_disabled": True},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["profile"]["is_disabled"] is True
|
assert r.json()["user"]["is_disabled"] is True
|
||||||
|
|
||||||
client.patch(
|
client.patch(
|
||||||
f"/api/v1/admin/users/{info['user_id']}",
|
f"/api/v1/admin/users/{info['user_id']}",
|
||||||
|
|||||||
+1
-1
@@ -264,7 +264,7 @@ def test_me(client: TestClient, monkeypatch) -> None:
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["user"]["email"] == email
|
assert body["user"]["email"] == email
|
||||||
assert body["profile"]["username"] == "我的信息"
|
assert body["user"]["username"] == "我的信息"
|
||||||
|
|
||||||
|
|
||||||
def test_me_unauthenticated(client: TestClient) -> None:
|
def test_me_unauthenticated(client: TestClient) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user