Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions alembic/versions/e2f3a4b5c6d7_add_mcp_token_to_subscriptions.py
Original file line number Diff line number Diff line change
@@ -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")
27 changes: 4 additions & 23 deletions src/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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로 이미 가입된 사용자가 있으면 기존 계정으로 로그인
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/subscription/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
81 changes: 77 additions & 4 deletions src/subscription/router.py
Original file line number Diff line number Diff line change
@@ -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()


Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/users/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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