Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.features.router import router as features_router
from app.grad_2025.router import private_router as grad_2025_private_router
from app.grad_2025.router import public_router as grad_2025_public_router
from app.posts.public_router import router as posts_public_router
from app.posts.router import router as posts_router
from app.slack.router import router as slack_router
from app.tags.router import router as tags_router
Expand All @@ -20,6 +21,7 @@
public_router.include_router(
grad_2025_public_router, prefix="/grad_2025", tags=["Grad 2025"]
)
public_router.include_router(posts_public_router, prefix="/posts", tags=["Posts"])

private_router = create_router(
prefix="/private",
Expand Down
10 changes: 10 additions & 0 deletions app/posts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from app.category.enums import Category
from app.models import Base
from app.utils.ulid import generate_ulid

if TYPE_CHECKING:
from app.users.models import User
Expand Down Expand Up @@ -51,6 +52,15 @@ class Post(Base):
autoincrement=True,
)

public_id: Mapped[str | None] = mapped_column(
String(26),
unique=True,
index=True,
nullable=True,
init=False,
default_factory=generate_ulid,
)

author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
author: Mapped["User"] = relationship(init=False, back_populates="posts")

Expand Down
39 changes: 39 additions & 0 deletions app/posts/public_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import asyncio

from fastapi import HTTPException

from app.comments.repository import CommentRepository
from app.core.router import create_router
from app.like.repository import PostLikeRepository

from .repository import PostRepository
from .schemas import PostPublicRead

router = create_router()


@router.get("/{public_id}")
async def read_post_public(
public_id: str,
post_repository: PostRepository,
post_like_repository: PostLikeRepository,
comment_repository: CommentRepository,
) -> PostPublicRead:
"""Public API: public_id로 게시물 조회 (인증 불필요)"""
post = await post_repository.find_by_public_id(public_id=public_id)

if not post:
raise HTTPException(status_code=404, detail="게시물을 찾을 수 없습니다.")

[like_count, comment_count, univ_major] = await asyncio.gather(
post_like_repository.count_by_id(post_id=post.id),
comment_repository.count_by_post_id(post_id=post.id),
post_repository.find_univ_major_by_post_id(post_id=post.id),
)

return PostPublicRead.from_post(
post,
like_count=like_count,
comment_count=comment_count,
univ_major=univ_major.name if univ_major else None,
)
11 changes: 11 additions & 0 deletions app/posts/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ async def find_by_id(self, post_id: int) -> Post | None:
)
return result.unique().scalar_one_or_none()

async def find_by_public_id(self, public_id: str) -> Post | None:
result = await self.session.execute(
select(Post)
.options(
joinedload(Post.author),
joinedload(Post.images),
)
.where(Post.public_id == public_id, Post.deleted_at.is_(None))
)
return result.unique().scalar_one_or_none()

async def find_all(
self,
*,
Expand Down
51 changes: 50 additions & 1 deletion app/posts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from app.schemas import APISchema
from app.storage.service import get_image_url
from app.tags.schemas import Tag
from app.users.schemas import UserRead
from app.users.schemas import UserPublicRead, UserRead
from app.utils.file_types import is_valid_image_type

from .models import Post, PostImage
Expand Down Expand Up @@ -52,12 +52,14 @@ class PostBase(APISchema):

class PostCompactRead(PostBase):
id: int
public_id: str | None
images: list[PostImageRead]

@classmethod
def from_post(cls, post: Post):
return cls(
id=post.id,
public_id=post.public_id,
title=post.title,
description=post.description,
category=post.category,
Expand All @@ -71,6 +73,7 @@ def from_post(cls, post: Post):

class PostRead(PostBase):
id: int
public_id: str | None
author: UserRead

images: list[PostImageRead]
Expand All @@ -97,6 +100,7 @@ def from_post(
):
return cls(
id=post.id,
public_id=post.public_id,
title=post.title,
description=post.description,
category=post.category,
Expand All @@ -115,6 +119,51 @@ def from_post(
)


class PostPublicRead(PostBase):
"""Public API용 Post 응답 스키마 (인증 불필요)"""

id: int
public_id: str | None
author: UserPublicRead

images: list[PostImageRead]
like_count: int
comment_count: int

created_at: datetime
updated_at: datetime

univ_major: str | None = None

@classmethod
def from_post(
cls,
post: Post,
*,
like_count: int,
comment_count: int,
univ_major: str | None = None,
):
return cls(
id=post.id,
public_id=post.public_id,
title=post.title,
description=post.description,
category=post.category,
author=UserPublicRead.from_user(post.author),
images=[
PostImageRead.from_post_image(image)
for image in sorted(post.images, key=lambda x: x.order)
],
created_at=post.created_at,
updated_at=post.updated_at,
like_count=like_count,
comment_count=comment_count,
tags=post.tags,
univ_major=univ_major,
)


class PostCreateUpdate(PostBase):
images: list[PostImageCreate] = Field(min_length=1)
univ_major: str | None = None
Expand Down
20 changes: 20 additions & 0 deletions app/users/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@
]


class UserPublicRead(APISchema):
"""Public API용 User 응답 스키마 (인증 불필요)"""

handle: Handle
nickname: str
profile_image: HttpUrl | None

@classmethod
def from_user(cls, user: User):
return cls(
handle=user.handle,
nickname=user.nickname,
profile_image=(
get_image_url(user.profile_image)
if user.profile_image is not None
else None
),
)


class UserRead(APISchema):
handle: Handle
nickname: str
Expand Down
6 changes: 6 additions & 0 deletions app/utils/ulid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from ulid import ULID


def generate_ulid() -> str:
"""새로운 ULID를 생성합니다."""
return str(ULID())
32 changes: 32 additions & 0 deletions migrations/versions/2026-01-25_add_post_public_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""empty message

Revision ID: 40fea8c77b2e
Revises: e8a40b987271
Create Date: 2026-01-25 18:39:34.286713

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "40fea8c77b2e"
down_revision: Union[str, None] = "e8a40b987271"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("post", sa.Column("public_id", sa.String(length=26), nullable=True))
op.create_index(op.f("ix_post_public_id"), "post", ["public_id"], unique=True)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_post_public_id"), table_name="post")
op.drop_column("post", "public_id")
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"psycopg[binary,pool]>=3.2.6",
"pydantic-settings>=2.8.1",
"pyjwt[crypto]>=2.10.1",
"python-ulid>=3.1.0",
"sqlalchemy[asyncio]>=2.0.39",
"types-aioboto3[full]>=14.1.0",
]
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading