Skip to content

Commit 492195b

Browse files
authored
Merge pull request #64 from RestTest-App/develop
멤버십 추가
2 parents 60d5ffe + 878e5bc commit 492195b

27 files changed

Lines changed: 1003 additions & 28 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""add membership and payment_history
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: f17e2374299a
5+
Create Date: 2025-11-30 00:00:00.000000
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 = 'a1b2c3d4e5f6'
16+
down_revision: Union[str, None] = 'f17e2374299a'
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+
# Add membership_tier to user table
24+
op.add_column(
25+
'user',
26+
sa.Column(
27+
'membership_tier',
28+
sa.String(20),
29+
nullable=False,
30+
server_default='FREE',
31+
comment="멤버십 등급 (FREE, PREMIUM)"
32+
)
33+
)
34+
35+
# Add subscription_expire_date to user table
36+
op.add_column(
37+
'user',
38+
sa.Column(
39+
'subscription_expire_date',
40+
sa.DateTime(),
41+
nullable=True,
42+
comment="구독 만료일"
43+
)
44+
)
45+
46+
# Create payment_history table
47+
op.create_table(
48+
'payment_history',
49+
sa.Column('id', sa.BigInteger(), nullable=False, comment="결제 이력 고유 식별값"),
50+
sa.Column('user_id', sa.BigInteger(), nullable=False, comment="사용자 ID"),
51+
sa.Column('membership_tier', sa.String(20), nullable=False, comment="구매한 멤버십 등급"),
52+
sa.Column('payment_amount', sa.Integer(), nullable=False, comment="결제 금액 (원)"),
53+
sa.Column('payment_method', sa.String(50), nullable=True, comment="결제 수단 (카드, 계좌이체 등)"),
54+
sa.Column('payment_status', sa.String(20), nullable=False, comment="결제 상태 (SUCCESS, FAILED, REFUND)"),
55+
sa.Column('subscription_start_date', sa.DateTime(), nullable=False, comment="구독 시작일"),
56+
sa.Column('subscription_end_date', sa.DateTime(), nullable=False, comment="구독 종료일"),
57+
sa.Column('created_at', sa.DateTime(), nullable=False, comment="결제 시간"),
58+
sa.Column('transaction_id', sa.String(255), nullable=True, comment="결제 고유 ID (PG사 제공)"),
59+
sa.Column('pg_provider', sa.String(50), nullable=True, comment="PG사 (토스페이, 네이버페이 등)"),
60+
sa.PrimaryKeyConstraint('id'),
61+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
62+
sa.UniqueConstraint('transaction_id')
63+
)
64+
65+
66+
def downgrade() -> None:
67+
"""Downgrade schema."""
68+
# Drop payment_history table
69+
op.drop_table('payment_history')
70+
71+
# Remove membership columns from user table
72+
op.drop_column('user', 'subscription_expire_date')
73+
op.drop_column('user', 'membership_tier')

‎api/v1/membership/__init__.py‎

Whitespace-only changes.
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
from fastapi import APIRouter, Depends
2+
from typing import Annotated
3+
from sqlalchemy.ext.asyncio import AsyncSession
4+
5+
from app.auth.dependency import get_current_user
6+
from database.dependency import get_db
7+
from domain.user.entity.user import User
8+
from domain.membership.service.membership_service import MembershipService
9+
from app.utils.dto.success import ok
10+
from pydantic import BaseModel, Field
11+
12+
13+
router = APIRouter()
14+
15+
16+
class UpgradeMembershipRequest(BaseModel):
17+
"""멤버십 업그레이드 요청 DTO"""
18+
membership_tier: str = Field(..., description="멤버십 등급 (FREE, PREMIUM)")
19+
payment_amount: int = Field(default=0, description="결제 금액 (원)")
20+
duration_days: int = Field(default=30, description="구독 기간 (일)")
21+
22+
23+
class MembershipInfoResponse(BaseModel):
24+
"""멤버십 정보 응답 DTO"""
25+
user_id: int
26+
membership_tier: str
27+
is_active: bool
28+
is_expired: bool
29+
subscription_expire_date: str | None
30+
31+
32+
@router.get("/info")
33+
async def get_membership_info(
34+
current_user: Annotated[User, Depends(get_current_user)]
35+
):
36+
"""
37+
멤버십 정보 조회
38+
39+
Returns:
40+
{
41+
"code": 200,
42+
"message": "멤버십 정보 조회 성공",
43+
"data": {
44+
"user_id": 1,
45+
"membership_tier": "FREE",
46+
"is_active": false,
47+
"is_expired": false,
48+
"subscription_expire_date": null
49+
}
50+
}
51+
"""
52+
subscription_status = await MembershipService.check_subscription_status(current_user)
53+
54+
membership_tier = getattr(current_user, 'membership_tier', 'FREE')
55+
56+
response = MembershipInfoResponse(
57+
user_id=current_user.id,
58+
membership_tier=membership_tier,
59+
is_active=subscription_status["is_active"],
60+
is_expired=subscription_status["is_expired"],
61+
subscription_expire_date=subscription_status["expire_date"].isoformat() if subscription_status["expire_date"] else None
62+
)
63+
64+
return ok(data=response.model_dump(), message="멤버십 정보 조회 성공")
65+
66+
67+
@router.patch("/upgrade")
68+
async def upgrade_membership(
69+
request: UpgradeMembershipRequest,
70+
current_user: Annotated[User, Depends(get_current_user)],
71+
db: Annotated[AsyncSession, Depends(get_db)]
72+
):
73+
"""
74+
멤버십 업그레이드 (테스트용)
75+
76+
Request Body:
77+
{
78+
"membership_tier": "PREMIUM",
79+
"payment_amount": 9900,
80+
"duration_days": 30
81+
}
82+
83+
Returns:
84+
{
85+
"code": 200,
86+
"message": "멤버십 업그레이드 성공",
87+
"data": {
88+
"user_id": 1,
89+
"previous_tier": "FREE",
90+
"new_tier": "PREMIUM",
91+
"subscription_expire_date": "2026-01-11T12:00:00"
92+
}
93+
}
94+
"""
95+
result = await MembershipService.upgrade_membership(
96+
db=db,
97+
user=current_user,
98+
membership_tier=request.membership_tier,
99+
payment_amount=request.payment_amount,
100+
duration_days=request.duration_days
101+
)
102+
103+
# subscription_expire_date가 datetime이면 문자열로 변환
104+
if result["subscription_expire_date"]:
105+
result["subscription_expire_date"] = result["subscription_expire_date"].isoformat()
106+
107+
return ok(data=result, message="멤버십 업그레이드 성공")
108+
109+
110+
@router.get("/payment-history")
111+
async def get_payment_history(
112+
current_user: Annotated[User, Depends(get_current_user)],
113+
db: Annotated[AsyncSession, Depends(get_db)]
114+
):
115+
"""
116+
결제 이력 조회
117+
118+
Returns:
119+
{
120+
"code": 200,
121+
"message": "결제 이력 조회 성공",
122+
"data": [
123+
{
124+
"id": 1,
125+
"membership_tier": "PREMIUM",
126+
"payment_amount": 9900,
127+
"payment_method": "TEST",
128+
"payment_status": "SUCCESS",
129+
"subscription_start_date": "2025-11-30T00:00:00",
130+
"subscription_end_date": "2025-12-30T00:00:00",
131+
"created_at": "2025-11-30T00:00:00",
132+
"transaction_id": "TEST_1_1234567890",
133+
"pg_provider": "TEST"
134+
}
135+
]
136+
}
137+
"""
138+
payment_history = await MembershipService.get_payment_history(
139+
db=db,
140+
user_id=current_user.id
141+
)
142+
143+
return ok(data=payment_history, message="결제 이력 조회 성공")
144+
145+
146+
@router.patch("/cancel")
147+
async def cancel_membership(
148+
current_user: Annotated[User, Depends(get_current_user)],
149+
db: Annotated[AsyncSession, Depends(get_db)]
150+
):
151+
"""
152+
멤버십 구독 해지
153+
154+
구독을 해지합니다. 만료일까지는 프리미엄 기능을 계속 사용할 수 있습니다.
155+
156+
Returns:
157+
{
158+
"code": 200,
159+
"message": "구독 해지가 완료되었습니다",
160+
"data": {
161+
"user_id": 1,
162+
"membership_tier": "PREMIUM",
163+
"subscription_expire_date": "2026-01-11T12:00:00",
164+
"days_remaining": 29,
165+
"message": "만료일까지 프리미엄 기능을 계속 사용할 수 있습니다."
166+
}
167+
}
168+
"""
169+
from datetime import datetime
170+
from exception.client_exception import BadRequestException
171+
172+
membership_tier = getattr(current_user, 'membership_tier', 'FREE')
173+
subscription_expire_date = getattr(current_user, 'subscription_expire_date', None)
174+
175+
# 이미 FREE 등급이면 해지할 구독이 없음
176+
if membership_tier == "FREE":
177+
raise BadRequestException(message="해지할 구독이 없습니다.")
178+
179+
# 구독 상태 확인
180+
subscription_status = await MembershipService.check_subscription_status(current_user)
181+
182+
if subscription_status["is_expired"]:
183+
raise BadRequestException(message="이미 만료된 구독입니다.")
184+
185+
# 남은 일수 계산
186+
if subscription_expire_date:
187+
now = datetime.now()
188+
days_remaining = (subscription_expire_date - now).days
189+
else:
190+
days_remaining = 0
191+
192+
# 실제 구독 해지 로직은 여기서 구현
193+
# (예: auto_renewal 플래그 설정, 또는 즉시 FREE로 변경 등)
194+
# 현재는 정보만 반환
195+
196+
response = {
197+
"user_id": current_user.id,
198+
"membership_tier": membership_tier,
199+
"subscription_expire_date": subscription_expire_date.isoformat() if subscription_expire_date else None,
200+
"days_remaining": days_remaining,
201+
"message": f"만료일({subscription_expire_date.date()})까지 프리미엄 기능을 계속 사용할 수 있습니다." if subscription_expire_date else "구독 해지가 완료되었습니다."
202+
}
203+
204+
return ok(data=response, message="구독 해지가 완료되었습니다")

‎api/v1/routers.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from api.v1.studybook import studybook_router
77
from api.v1.studybook import studybook_question_router
88
from api.v1.test import test_router
9+
from api.v1.membership import membership_router
910
router = APIRouter(prefix="/api/v1")
1011

1112
router.include_router(review_router.router, tags=["review"], prefix="/review")
@@ -15,4 +16,4 @@
1516
router.include_router(studybook_question_router.router, tags=["studybook_question"], prefix="/studybook-question")
1617
router.include_router(test_router.router, tags=["test"], prefix="/test")
1718
router.include_router(certificate_router.router, tags=["certificate"], prefix="/user")
18-
router.include_router(review_router.router, tags=["review"], prefix="/review")
19+
router.include_router(membership_router.router, tags=["membership"], prefix="/membership")

‎api/v1/studybook/studybook_router.py‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# api/v1/studybook/studybook_router.py
22

3-
from fastapi import APIRouter, UploadFile, File, Path, Depends
3+
from fastapi import APIRouter, UploadFile, File, Form, Path, Depends
44
from typing import Annotated
55
from datetime import datetime
66

@@ -27,7 +27,7 @@
2727
from app.utils.dummy_questions import dummy_questions
2828
from app.studybook.usecase.studybook_usecase import upload_my_studybook_by_dummy_usecase
2929

30-
from app.auth.dependency import get_current_user
30+
from app.auth.dependency import get_current_user, check_api_rate_limit
3131
router = APIRouter()
3232
#
3333
# def get_dummy_user() -> User:
@@ -63,23 +63,34 @@ async def upload_my_studybook_by_dummy(
6363
@router.post("/upload-my-studybook", response_model=UploadStudybookResponseDTO)
6464
async def upload_my_studybook(
6565
files: list[UploadFile] = File(...),
66-
answers: str = None,
67-
question_count: int = None,
66+
copyright_agreed: bool = Form(...), # 저작권 동의 필수
67+
answers: str = Form(None),
68+
question_count: int = Form(None),
6869
*,
6970
current_user: Annotated[User, Depends(get_current_user)],
70-
db: Annotated[Session, Depends(get_db)]
71+
db: Annotated[Session, Depends(get_db)],
72+
_: Annotated[None, Depends(check_api_rate_limit)] # Rate Limit 체크
7173
):
7274
"""
7375
통합 문제집 업로드 API (PDF 또는 이미지)
7476
7577
- PDF: 1개 파일만 업로드 가능
7678
- 이미지: 여러 개 파일 업로드 가능
7779
78-
Query Parameters:
80+
Form Data Parameters:
7981
files: 업로드할 파일 (PDF 또는 이미지)
82+
copyright_agreed: 저작권 동의 여부 (필수, true 필요)
8083
answers: JSON 형식의 정답 리스트 (선택사항)
8184
question_count: 예상 문제 개수 (PDF 전용, 선택사항)
8285
"""
86+
from exception.client_exception import BadRequestException
87+
88+
# 저작권 동의 확인
89+
if not copyright_agreed:
90+
raise BadRequestException(
91+
message="저작권 관련 법률을 준수하기 위해 동의가 필요합니다. 타인의 저작물을 무단으로 업로드하지 마세요."
92+
)
93+
8394
answer_list = None
8495
if answers:
8596
import json

0 commit comments

Comments
 (0)