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)