Skip to content

Commit 1727c40

Browse files
swa07016Copilot
andauthored
[NL-58] 오늘의 운세 리소스/유저 오늘의 운세 관계 테이블 추가, 관리자용 오늘의 운세 CRUD/유저용 오늘의 운세 조회 API 구현 (#12)
* feat: daily fortune resources table 생성 및 API 구현 * feat: user daily fortune summary 테이블 생성 및 API 구현 * Update src/fortune/admin_router.py Co-authored-by: Copilot <[email protected]> * Update migrations/env.py Co-authored-by: Copilot <[email protected]> * Update src/fortune/service.py Co-authored-by: Copilot <[email protected]> * fix: user daily fortune summary 마이그레이션 재생성 * refactor: session 주입 코드 Repository 계층으로 이동, 유저 오늘의 운세 API Users 라우터에 작성 --------- Co-authored-by: Copilot <[email protected]>
1 parent 6aea5c0 commit 1727c40

File tree

12 files changed

+455
-1
lines changed

12 files changed

+455
-1
lines changed

migrations/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
# 모든 도메인 모델을 import하여 메타데이터에 포함해야 함
3131
from src.users.entities.models import User
3232
from src.lotto.entities.models import LottoStatistics, LottoDraws
33+
from src.fortune.entities.models import DailyFortuneResource, UserDailyFortuneSummary
3334

3435
# 모델 메타데이터 설정
3536
target_metadata = Base.metadata
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""add daily fortune resources
2+
3+
Revision ID: 5f0e5c266c72
4+
Revises: 05818a33a6e5
5+
Create Date: 2025-08-09 13:17:47.255785
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '5f0e5c266c72'
16+
down_revision: Union[str, Sequence[str], None] = '05818a33a6e5'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table('daily_fortune_resources',
25+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='운세 고유 ID'),
26+
sa.Column('publish_date', sa.Date(), nullable=False, comment='운세 발행 날짜'),
27+
sa.Column('fortune_type', sa.Enum('GUIIN_INITIAL', 'LUCKY_OBJECT', 'PERFECT_TIMING', 'TABOO_OF_DAY', name='fortunetype'), nullable=False, comment='운세 항목 종류 (귀인의 초성 등)'),
28+
sa.Column('image_url', sa.String(length=1000), nullable=False, comment='이미지 URL 또는 경로'),
29+
sa.Column('description', sa.String(length=1000), nullable=False, comment='운세 설명'),
30+
sa.Column('created_at', sa.DateTime(), nullable=False),
31+
sa.Column('updated_at', sa.DateTime(), nullable=False),
32+
sa.CheckConstraint('length(description) > 0', name='ck_fortune_description_len'),
33+
sa.CheckConstraint('length(image_url) > 0', name='ck_fortune_image_url_len'),
34+
sa.PrimaryKeyConstraint('id')
35+
)
36+
# ### end Alembic commands ###
37+
38+
39+
def downgrade() -> None:
40+
"""Downgrade schema."""
41+
# ### commands auto generated by Alembic - please adjust! ###
42+
op.drop_table('daily_fortune_resources')
43+
# ### end Alembic commands ###
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""add user daily fortune summary
2+
3+
Revision ID: e082b238c1e3
4+
Revises: 5f0e5c266c72
5+
Create Date: 2025-08-12 16:15:00.678563
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'e082b238c1e3'
16+
down_revision: Union[str, Sequence[str], None] = '5f0e5c266c72'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table('user_daily_fortune_summary',
25+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='운세 기록 고유 ID'),
26+
sa.Column('user_id', sa.String(length=255), nullable=False, comment='유저 ID'),
27+
sa.Column('daily_fortune_resource_id', sa.Integer(), nullable=False, comment='운세 리소스 ID'),
28+
sa.Column('fortune_date', sa.Date(), nullable=False, comment='운세 날짜'),
29+
sa.Column('created_at', sa.DateTime(), nullable=False),
30+
sa.Column('updated_at', sa.DateTime(), nullable=False),
31+
sa.ForeignKeyConstraint(['daily_fortune_resource_id'], ['daily_fortune_resources.id'], ),
32+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
33+
sa.PrimaryKeyConstraint('id')
34+
)
35+
# ### end Alembic commands ###
36+
37+
38+
def downgrade() -> None:
39+
"""Downgrade schema."""
40+
# ### commands auto generated by Alembic - please adjust! ###
41+
op.drop_table('user_daily_fortune_summary')
42+
# ### end Alembic commands ###

src/fortune/__init__.py

Whitespace-only changes.

src/fortune/entities/enums.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# src/fortune/entities/enums.py
2+
from enum import Enum
3+
4+
5+
class FortuneType(str, Enum):
6+
"""운세 항목 종류"""
7+
GUIIN_INITIAL = "귀인의 초성"
8+
LUCKY_OBJECT = "행운의 오브제"
9+
PERFECT_TIMING = "절호의 타이밍"
10+
TABOO_OF_DAY = "오늘의 금기"

src/fortune/entities/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# src/fortune/entities/models.py
2+
from sqlalchemy import Column, Integer, Date, Enum as SAEnum, String, CheckConstraint, ForeignKey
3+
from sqlalchemy.orm import relationship
4+
from src.config.database import Base
5+
from src.fortune.entities.enums import FortuneType
6+
7+
8+
class DailyFortuneResource(Base):
9+
__tablename__ = "daily_fortune_resources"
10+
11+
id = Column(Integer, primary_key=True, autoincrement=True, comment="운세 고유 ID")
12+
publish_date = Column(Date, nullable=False, comment="운세 발행 날짜")
13+
fortune_type = Column(SAEnum(FortuneType), nullable=False, comment="운세 항목 종류 (귀인의 초성 등)")
14+
image_url = Column(String(1000), nullable=False, comment="이미지 URL 또는 경로")
15+
description = Column(String(1000), nullable=False, comment="운세 설명")
16+
17+
__table_args__ = (
18+
CheckConstraint("length(image_url) > 0", name="ck_fortune_image_url_len"),
19+
CheckConstraint("length(description) > 0", name="ck_fortune_description_len"),
20+
)
21+
22+
class UserDailyFortuneSummary(Base):
23+
__tablename__ = "user_daily_fortune_summary"
24+
25+
id = Column(Integer, primary_key=True, autoincrement=True, comment="운세 기록 고유 ID")
26+
user_id = Column(String(255), ForeignKey("users.id"), nullable=False, comment="유저 ID")
27+
daily_fortune_resource_id = Column(Integer, ForeignKey("daily_fortune_resources.id"), nullable=False, comment="운세 리소스 ID")
28+
fortune_date = Column(Date, nullable=False, comment="운세 날짜")

src/fortune/entities/schemas.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# src/fortune/entities/schemas.py
2+
from datetime import date
3+
from typing import List, Optional
4+
from src.config.schemas import CommonBase
5+
from src.fortune.entities.enums import FortuneType
6+
7+
8+
class DailyFortuneResource(CommonBase):
9+
id: int
10+
publish_date: date
11+
fortune_type: FortuneType
12+
image_url: str
13+
description: str
14+
15+
16+
class DailyFortuneResourceList(CommonBase):
17+
items: List[DailyFortuneResource]
18+
next_cursor: Optional[int] = None
19+
20+
21+
class DailyFortuneResourceCreate(CommonBase):
22+
publish_date: date
23+
fortune_type: FortuneType
24+
image_url: str
25+
description: str
26+
27+
28+
class DailyFortuneResourceUpdate(CommonBase):
29+
publish_date: Optional[date] = None
30+
fortune_type: Optional[FortuneType] = None
31+
image_url: Optional[str] = None
32+
description: Optional[str] = None
33+
34+
class UserDailyFortuneSummary(CommonBase):
35+
id: int
36+
user_id: str
37+
fortune_date: date
38+
fortune_type: FortuneType
39+
image_url: str
40+
description: str

src/fortune/repository.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# src/fortune/repository.py
2+
from datetime import date
3+
from typing import List, Optional, Tuple
4+
from fastapi import Depends
5+
from sqlalchemy import select, desc, and_
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
from src.common.dependencies import get_db_session
8+
from src.fortune.entities.models import UserDailyFortuneSummary as UserDailyFortuneSummaryModel, DailyFortuneResource as DailyFortuneResourceModel
9+
from src.fortune.entities.enums import FortuneType
10+
from src.fortune.entities.schemas import UserDailyFortuneSummary
11+
12+
13+
class FortuneRepository:
14+
def __init__(self, session: AsyncSession = Depends(get_db_session)):
15+
self.session = session
16+
17+
async def create_fortune(
18+
self,
19+
publish_date: date,
20+
fortune_type: FortuneType,
21+
image_url: str,
22+
description: str
23+
) -> DailyFortuneResourceModel:
24+
model = DailyFortuneResourceModel(
25+
publish_date=publish_date,
26+
fortune_type=fortune_type,
27+
image_url=image_url,
28+
description=description,
29+
)
30+
self.session.add(model)
31+
await self.session.commit()
32+
await self.session.refresh(model)
33+
return model # DB 모델 반환
34+
35+
async def update_fortune(
36+
self,
37+
resource_id: int,
38+
publish_date: Optional[date],
39+
fortune_type: Optional[FortuneType],
40+
image_url: Optional[str],
41+
description: Optional[str]
42+
) -> DailyFortuneResourceModel:
43+
model = await self.get_fortune_by_id(resource_id)
44+
if not model:
45+
raise ValueError("리소스를 찾을 수 없습니다.")
46+
47+
if publish_date is not None:
48+
model.publish_date = publish_date
49+
if fortune_type is not None:
50+
model.fortune_type = fortune_type
51+
if image_url is not None:
52+
model.image_url = image_url
53+
if description is not None:
54+
model.description = description
55+
56+
await self.session.commit()
57+
await self.session.refresh(model)
58+
return model
59+
60+
async def delete_fortune(self, resource_id: int) -> None:
61+
model = await self.get_fortune_by_id(resource_id)
62+
if not model:
63+
raise ValueError("리소스를 찾을 수 없습니다.")
64+
await self.session.delete(model)
65+
await self.session.commit()
66+
67+
async def get_fortunes(
68+
self,
69+
cursor: Optional[int] = None,
70+
limit: int = 10,
71+
publish_date: Optional[date] = None,
72+
fortune_type: Optional[FortuneType] = None,
73+
) -> Tuple[List[DailyFortuneResourceModel], Optional[int]]:
74+
query = select(DailyFortuneResourceModel).order_by(desc(DailyFortuneResourceModel.id))
75+
76+
conditions = []
77+
if cursor:
78+
conditions.append(DailyFortuneResourceModel.id < cursor)
79+
if publish_date:
80+
conditions.append(DailyFortuneResourceModel.publish_date == publish_date)
81+
if fortune_type:
82+
conditions.append(DailyFortuneResourceModel.fortune_type == fortune_type)
83+
84+
if conditions:
85+
query = query.where(and_(*conditions))
86+
87+
query = query.limit(limit)
88+
result = await self.session.execute(query)
89+
items = result.scalars().all()
90+
91+
next_cursor = items[-1].id if len(items) == limit else None
92+
return items, next_cursor
93+
94+
async def get_fortune_by_id(self, resource_id: int) -> Optional[DailyFortuneResourceModel]:
95+
return await self.session.get(DailyFortuneResourceModel, resource_id)
96+
97+
async def get_user_daily_fortune_summaries(
98+
self, user_id: str, fortune_date: date
99+
) -> List[UserDailyFortuneSummary]:
100+
query = select(
101+
UserDailyFortuneSummaryModel.id,
102+
UserDailyFortuneSummaryModel.user_id,
103+
UserDailyFortuneSummaryModel.fortune_date,
104+
DailyFortuneResourceModel.fortune_type,
105+
DailyFortuneResourceModel.image_url,
106+
DailyFortuneResourceModel.description
107+
).join(
108+
DailyFortuneResourceModel,
109+
UserDailyFortuneSummaryModel.daily_fortune_resource_id == DailyFortuneResourceModel.id
110+
).filter(
111+
UserDailyFortuneSummaryModel.user_id == user_id,
112+
UserDailyFortuneSummaryModel.fortune_date == fortune_date
113+
)
114+
115+
result = await self.session.execute(query)
116+
summaries = result.fetchall()
117+
118+
# 반환할 데이터를 Pydantic 모델로 변환하여 반환
119+
return [
120+
UserDailyFortuneSummary(
121+
id=summary[0],
122+
user_id=summary[1],
123+
fortune_date=summary[2],
124+
fortune_type=summary[3],
125+
image_url=summary[4],
126+
description=summary[5]
127+
)
128+
for summary in summaries
129+
]

src/fortune/router.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# src/fortune/router.py
2+
from datetime import date
3+
from typing import Optional
4+
from fastapi import APIRouter, Depends, Query, Path, HTTPException, status
5+
from src.fortune.entities.schemas import (
6+
DailyFortuneResource,
7+
DailyFortuneResourceCreate,
8+
DailyFortuneResourceUpdate,
9+
DailyFortuneResourceList,
10+
)
11+
from src.fortune.service import FortuneService
12+
from src.fortune.entities.enums import FortuneType
13+
14+
fortune_router = APIRouter(prefix="/admin/fortune", tags=["fortune-admin"])
15+
16+
@fortune_router.get(
17+
"/resources",
18+
response_model=DailyFortuneResourceList,
19+
summary="운세 리소스 목록 조회 (관리자)",
20+
)
21+
async def list_fortune_resources(
22+
cursor: Optional[int] = Query(None, description="다음 페이지 조회를 위한 cursor 값 (id)"),
23+
limit: int = Query(10, ge=1, le=100, description="한 페이지 크기"),
24+
publish_date: Optional[date] = Query(None, description="발행일 필터"),
25+
fortune_type: Optional[FortuneType] = Query(None, description="유형 필터"),
26+
service: FortuneService = Depends(),
27+
):
28+
return await service.list_fortunes(cursor, limit, publish_date, fortune_type)
29+
30+
31+
@fortune_router.post(
32+
"/resources",
33+
response_model=DailyFortuneResource,
34+
status_code=status.HTTP_201_CREATED,
35+
summary="운세 리소스 등록 (관리자)",
36+
)
37+
async def create_fortune_resource(
38+
body: DailyFortuneResourceCreate,
39+
service: FortuneService = Depends(),
40+
):
41+
return await service.create_fortune(body)
42+
43+
44+
@fortune_router.patch(
45+
"/resources/{resource_id}",
46+
response_model=DailyFortuneResource,
47+
summary="운세 리소스 수정 (관리자)",
48+
)
49+
async def update_fortune_resource(
50+
body: DailyFortuneResourceUpdate,
51+
resource_id: int = Path(..., description="운세 리소스 ID"),
52+
service: FortuneService = Depends(),
53+
):
54+
return await service.update_fortune(resource_id, body)
55+
56+
57+
@fortune_router.delete(
58+
"/resources/{resource_id}",
59+
status_code=status.HTTP_204_NO_CONTENT,
60+
summary="운세 리소스 삭제 (관리자)",
61+
)
62+
async def delete_fortune_resource(
63+
resource_id: int = Path(..., description="운세 리소스 ID"),
64+
service: FortuneService = Depends(),
65+
):
66+
await service.delete_fortune(resource_id)
67+
# 204 No Content

0 commit comments

Comments
 (0)