From f5d4663cfc428b22f89e051d7f314b80581b336b Mon Sep 17 00:00:00 2001 From: GoGangH Date: Tue, 24 Feb 2026 21:00:14 +0900 Subject: [PATCH] =?UTF-8?q?[feature]=20mcp=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20router=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...a4b5c6d7_add_mcp_token_to_subscriptions.py | 25 ++++++ src/auth/service.py | 27 +------ src/subscription/models.py | 5 ++ src/subscription/router.py | 81 ++++++++++++++++++- src/users/service.py | 9 ++- 5 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 alembic/versions/e2f3a4b5c6d7_add_mcp_token_to_subscriptions.py diff --git a/alembic/versions/e2f3a4b5c6d7_add_mcp_token_to_subscriptions.py b/alembic/versions/e2f3a4b5c6d7_add_mcp_token_to_subscriptions.py new file mode 100644 index 0000000..befc626 --- /dev/null +++ b/alembic/versions/e2f3a4b5c6d7_add_mcp_token_to_subscriptions.py @@ -0,0 +1,25 @@ +"""add mcp_token to subscriptions + +Revision ID: e2f3a4b5c6d7 +Revises: d1e2f3a4b5c6 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +revision = "e2f3a4b5c6d7" +down_revision = "d1e2f3a4b5c6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "subscriptions", + sa.Column("token", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("subscriptions", "token") diff --git a/src/auth/service.py b/src/auth/service.py index e123749..f9b0edc 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -23,7 +23,6 @@ from src.auth.schemas import OAuthUserCreate from src.config import settings from src.security import create_access_token, create_refresh_token -from src.subscription.models import Subscription, SubscriptionPlan, SubscriptionType from src.users import service as user_service from src.users.models import OAuthProvider, User @@ -129,6 +128,8 @@ async def _find_or_create_user( ) if existing_user: + if not existing_user.is_active: + raise OAuthError("탈퇴한 계정입니다. 고객센터에 문의해주세요.") return existing_user, False # 2) 동일 email로 이미 가입된 사용자가 있으면 기존 계정으로 로그인 @@ -137,6 +138,8 @@ async def _find_or_create_user( ) if email_user: + if not email_user.is_active: + raise OAuthError("탈퇴한 계정입니다. 고객센터에 문의해주세요.") return email_user, False try: @@ -173,28 +176,6 @@ async def _generate_tokens_for_user( session=session, db_user=user, refresh_token=refresh_token ) - # 신규 유저: MCP 무료 체험 구독 (30일) 자동 생성 - if is_new_user: - try: - now = datetime.now(timezone.utc) - mcp_subscription = Subscription( - user_id=user.id, - sub_type=SubscriptionType.MCP, - is_active=True, - plan=SubscriptionPlan.FREE_TRIAL, - started_at=now, - expires_at=now + timedelta(days=30), - ) - session.add(mcp_subscription) - await session.commit() - except Exception: - await session.rollback() - logger.warning( - "Failed to create MCP free trial subscription for user %s", - user.id, - exc_info=True, - ) - return { "is_new_user": is_new_user, "access_token": access_token, diff --git a/src/subscription/models.py b/src/subscription/models.py index 8999bd4..2a51457 100644 --- a/src/subscription/models.py +++ b/src/subscription/models.py @@ -44,6 +44,11 @@ class Subscription(SQLModel, table=True): ), ) + token: str | None = Field( + default=None, + sa_column=Column(String, nullable=True), + ) + started_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), sa_column=Column(DateTime(timezone=True), nullable=False), diff --git a/src/subscription/router.py b/src/subscription/router.py index e2a6ea6..68dda10 100644 --- a/src/subscription/router.py +++ b/src/subscription/router.py @@ -1,15 +1,24 @@ """구독 API 엔드포인트.""" -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from src.common.responses import RESPONSES_CRUD_WITH_AUTH, ERROR_403_FORBIDDEN +from src.common.responses import create_responses +from src.security import create_mcp_token -from src.common.responses import RESPONSES_CRUD_WITH_AUTH from src.users.dependencies import ActiveUser, SessionDep -from .models import Subscription, SubscriptionType +from .models import Subscription, SubscriptionPlan, SubscriptionType from .service import get_active_subscription + +class McpTokenResponse(BaseModel): + token: str + + router = APIRouter() @@ -51,6 +60,70 @@ async def get_my_subscriptions( return subs +@router.get( + "/me/mcp-token", + response_model=McpTokenResponse, + responses=create_responses( + RESPONSES_CRUD_WITH_AUTH, + {403: ERROR_403_FORBIDDEN}, + ), + summary="MCP 서버 토큰 발급", +) +async def get_mcp_token( + session: SessionDep, + current_user: ActiveUser, +): + """ + 활성 MCP 구독이 있는 유저에게 MCP 서버 접속용 JWT 토큰을 발급합니다. + + - 구독이 없거나 만료된 경우 403을 반환합니다. + - 만료 여부는 호출 시점에 실시간으로 확인합니다. + """ + from sqlmodel import select + + result = await session.execute( + select(Subscription).where( + Subscription.user_id == current_user.id, + Subscription.sub_type == SubscriptionType.MCP, + ) + ) + sub = result.scalar_one_or_none() + + now = datetime.now(timezone.utc) + + if sub is None: + # 구독 이력 없음 → 무료 체험 발급 (최초 1회) + sub = Subscription( + user_id=current_user.id, + sub_type=SubscriptionType.MCP, + is_active=True, + plan=SubscriptionPlan.FREE_TRIAL, + started_at=now, + expires_at=now + timedelta(days=30), + token=create_mcp_token(subject=str(current_user.id)), + ) + session.add(sub) + await session.commit() + await session.refresh(sub) + return McpTokenResponse(token=sub.token) + + # 구독은 있지만 만료됨 → 결제 필요 + if not sub.is_active or (sub.expires_at is not None and sub.expires_at < now): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="MCP 구독이 만료되었습니다. 결제 후 이용해주세요.", + ) + + # 활성 구독이지만 토큰 없음 (결제 등 다른 경로로 구독 생성된 경우) + if not sub.token: + sub.token = create_mcp_token(subject=str(current_user.id)) + session.add(sub) + await session.commit() + await session.refresh(sub) + + return McpTokenResponse(token=sub.token) + + @router.get( "/me/{type}", response_model=Subscription | None, diff --git a/src/users/service.py b/src/users/service.py index f9895fa..5910d52 100644 --- a/src/users/service.py +++ b/src/users/service.py @@ -69,9 +69,14 @@ async def get_user_by_oauth( async def delete_user(*, session: AsyncSession, user_id: UUID) -> Optional[User]: - """Delete a user.""" + """Soft delete a user (is_active=False, refresh_token 무효화). + oauth_provider_id를 보존해 재가입 시 무료 체험 중복 발급을 차단한다. + """ user = await session.get(User, user_id) if user: - await session.delete(user) + user.is_active = False + user.refresh_token = None + session.add(user) await session.commit() + await session.refresh(user) return user \ No newline at end of file