Skip to content

Commit f5d4663

Browse files
committed
[feature] mcp 토큰 발급 router 추가
1 parent d712656 commit f5d4663

5 files changed

Lines changed: 118 additions & 29 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""add mcp_token to subscriptions
2+
3+
Revision ID: e2f3a4b5c6d7
4+
Revises: d1e2f3a4b5c6
5+
Create Date: 2025-01-01 00:00:00.000000
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
revision = "e2f3a4b5c6d7"
12+
down_revision = "d1e2f3a4b5c6"
13+
branch_labels = None
14+
depends_on = None
15+
16+
17+
def upgrade() -> None:
18+
op.add_column(
19+
"subscriptions",
20+
sa.Column("token", sa.String(), nullable=True),
21+
)
22+
23+
24+
def downgrade() -> None:
25+
op.drop_column("subscriptions", "token")

src/auth/service.py

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
from src.auth.schemas import OAuthUserCreate
2424
from src.config import settings
2525
from src.security import create_access_token, create_refresh_token
26-
from src.subscription.models import Subscription, SubscriptionPlan, SubscriptionType
2726
from src.users import service as user_service
2827
from src.users.models import OAuthProvider, User
2928

@@ -129,6 +128,8 @@ async def _find_or_create_user(
129128
)
130129

131130
if existing_user:
131+
if not existing_user.is_active:
132+
raise OAuthError("탈퇴한 계정입니다. 고객센터에 문의해주세요.")
132133
return existing_user, False
133134

134135
# 2) 동일 email로 이미 가입된 사용자가 있으면 기존 계정으로 로그인
@@ -137,6 +138,8 @@ async def _find_or_create_user(
137138
)
138139

139140
if email_user:
141+
if not email_user.is_active:
142+
raise OAuthError("탈퇴한 계정입니다. 고객센터에 문의해주세요.")
140143
return email_user, False
141144

142145
try:
@@ -173,28 +176,6 @@ async def _generate_tokens_for_user(
173176
session=session, db_user=user, refresh_token=refresh_token
174177
)
175178

176-
# 신규 유저: MCP 무료 체험 구독 (30일) 자동 생성
177-
if is_new_user:
178-
try:
179-
now = datetime.now(timezone.utc)
180-
mcp_subscription = Subscription(
181-
user_id=user.id,
182-
sub_type=SubscriptionType.MCP,
183-
is_active=True,
184-
plan=SubscriptionPlan.FREE_TRIAL,
185-
started_at=now,
186-
expires_at=now + timedelta(days=30),
187-
)
188-
session.add(mcp_subscription)
189-
await session.commit()
190-
except Exception:
191-
await session.rollback()
192-
logger.warning(
193-
"Failed to create MCP free trial subscription for user %s",
194-
user.id,
195-
exc_info=True,
196-
)
197-
198179
return {
199180
"is_new_user": is_new_user,
200181
"access_token": access_token,

src/subscription/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ class Subscription(SQLModel, table=True):
4444
),
4545
)
4646

47+
token: str | None = Field(
48+
default=None,
49+
sa_column=Column(String, nullable=True),
50+
)
51+
4752
started_at: datetime = Field(
4853
default_factory=lambda: datetime.now(timezone.utc),
4954
sa_column=Column(DateTime(timezone=True), nullable=False),

src/subscription/router.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
"""구독 API 엔드포인트."""
22

3-
from datetime import datetime, timezone
3+
from datetime import datetime, timedelta, timezone
44

5-
from fastapi import APIRouter
5+
from fastapi import APIRouter, HTTPException, status
6+
from pydantic import BaseModel
7+
8+
from src.common.responses import RESPONSES_CRUD_WITH_AUTH, ERROR_403_FORBIDDEN
9+
from src.common.responses import create_responses
10+
from src.security import create_mcp_token
611

7-
from src.common.responses import RESPONSES_CRUD_WITH_AUTH
812
from src.users.dependencies import ActiveUser, SessionDep
913

10-
from .models import Subscription, SubscriptionType
14+
from .models import Subscription, SubscriptionPlan, SubscriptionType
1115
from .service import get_active_subscription
1216

17+
18+
class McpTokenResponse(BaseModel):
19+
token: str
20+
21+
1322
router = APIRouter()
1423

1524

@@ -51,6 +60,70 @@ async def get_my_subscriptions(
5160
return subs
5261

5362

63+
@router.get(
64+
"/me/mcp-token",
65+
response_model=McpTokenResponse,
66+
responses=create_responses(
67+
RESPONSES_CRUD_WITH_AUTH,
68+
{403: ERROR_403_FORBIDDEN},
69+
),
70+
summary="MCP 서버 토큰 발급",
71+
)
72+
async def get_mcp_token(
73+
session: SessionDep,
74+
current_user: ActiveUser,
75+
):
76+
"""
77+
활성 MCP 구독이 있는 유저에게 MCP 서버 접속용 JWT 토큰을 발급합니다.
78+
79+
- 구독이 없거나 만료된 경우 403을 반환합니다.
80+
- 만료 여부는 호출 시점에 실시간으로 확인합니다.
81+
"""
82+
from sqlmodel import select
83+
84+
result = await session.execute(
85+
select(Subscription).where(
86+
Subscription.user_id == current_user.id,
87+
Subscription.sub_type == SubscriptionType.MCP,
88+
)
89+
)
90+
sub = result.scalar_one_or_none()
91+
92+
now = datetime.now(timezone.utc)
93+
94+
if sub is None:
95+
# 구독 이력 없음 → 무료 체험 발급 (최초 1회)
96+
sub = Subscription(
97+
user_id=current_user.id,
98+
sub_type=SubscriptionType.MCP,
99+
is_active=True,
100+
plan=SubscriptionPlan.FREE_TRIAL,
101+
started_at=now,
102+
expires_at=now + timedelta(days=30),
103+
token=create_mcp_token(subject=str(current_user.id)),
104+
)
105+
session.add(sub)
106+
await session.commit()
107+
await session.refresh(sub)
108+
return McpTokenResponse(token=sub.token)
109+
110+
# 구독은 있지만 만료됨 → 결제 필요
111+
if not sub.is_active or (sub.expires_at is not None and sub.expires_at < now):
112+
raise HTTPException(
113+
status_code=status.HTTP_403_FORBIDDEN,
114+
detail="MCP 구독이 만료되었습니다. 결제 후 이용해주세요.",
115+
)
116+
117+
# 활성 구독이지만 토큰 없음 (결제 등 다른 경로로 구독 생성된 경우)
118+
if not sub.token:
119+
sub.token = create_mcp_token(subject=str(current_user.id))
120+
session.add(sub)
121+
await session.commit()
122+
await session.refresh(sub)
123+
124+
return McpTokenResponse(token=sub.token)
125+
126+
54127
@router.get(
55128
"/me/{type}",
56129
response_model=Subscription | None,

src/users/service.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,14 @@ async def get_user_by_oauth(
6969

7070

7171
async def delete_user(*, session: AsyncSession, user_id: UUID) -> Optional[User]:
72-
"""Delete a user."""
72+
"""Soft delete a user (is_active=False, refresh_token 무효화).
73+
oauth_provider_id를 보존해 재가입 시 무료 체험 중복 발급을 차단한다.
74+
"""
7375
user = await session.get(User, user_id)
7476
if user:
75-
await session.delete(user)
77+
user.is_active = False
78+
user.refresh_token = None
79+
session.add(user)
7680
await session.commit()
81+
await session.refresh(user)
7782
return user

0 commit comments

Comments
 (0)