diff --git a/app/api.py b/app/api.py index bfa2faa..4fbfe6f 100644 --- a/app/api.py +++ b/app/api.py @@ -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 @@ -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", diff --git a/app/posts/models.py b/app/posts/models.py index 62f1f45..fcbf38e 100644 --- a/app/posts/models.py +++ b/app/posts/models.py @@ -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 @@ -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") diff --git a/app/posts/public_router.py b/app/posts/public_router.py new file mode 100644 index 0000000..0ead49e --- /dev/null +++ b/app/posts/public_router.py @@ -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, + ) diff --git a/app/posts/repository.py b/app/posts/repository.py index b68aa9b..e813967 100644 --- a/app/posts/repository.py +++ b/app/posts/repository.py @@ -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, *, diff --git a/app/posts/schemas.py b/app/posts/schemas.py index 5c22f84..f298d62 100644 --- a/app/posts/schemas.py +++ b/app/posts/schemas.py @@ -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 @@ -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, @@ -71,6 +73,7 @@ def from_post(cls, post: Post): class PostRead(PostBase): id: int + public_id: str | None author: UserRead images: list[PostImageRead] @@ -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, @@ -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 diff --git a/app/users/schemas.py b/app/users/schemas.py index 3136b6b..3059adf 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -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 diff --git a/app/utils/ulid.py b/app/utils/ulid.py new file mode 100644 index 0000000..693ec22 --- /dev/null +++ b/app/utils/ulid.py @@ -0,0 +1,6 @@ +from ulid import ULID + + +def generate_ulid() -> str: + """새로운 ULID를 생성합니다.""" + return str(ULID()) diff --git a/migrations/versions/2026-01-25_add_post_public_id.py b/migrations/versions/2026-01-25_add_post_public_id.py new file mode 100644 index 0000000..2555086 --- /dev/null +++ b/migrations/versions/2026-01-25_add_post_public_id.py @@ -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 ### diff --git a/pyproject.toml b/pyproject.toml index 7d68452..7e09d5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/uv.lock b/uv.lock index 68257c4..1bc4023 100644 --- a/uv.lock +++ b/uv.lock @@ -974,6 +974,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-ulid" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175, upload-time = "2025-08-18T16:09:26.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1081,6 +1090,7 @@ dependencies = [ { name = "psycopg", extra = ["binary", "pool"] }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, + { name = "python-ulid" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "types-aioboto3", extra = ["full"] }, ] @@ -1108,6 +1118,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2.6" }, { name = "pydantic-settings", specifier = ">=2.8.1" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, + { name = "python-ulid", specifier = ">=3.1.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.39" }, { name = "types-aioboto3", extras = ["full"], specifier = ">=14.1.0" }, ]