diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index b750819cc..1848cdd7b 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -6,6 +6,7 @@ from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.classification_jobs import router as classification_jobs_router from app.api.routes.duplication import router as duplication_router from app.api.routes.generated_videos import router as generated_videos_router +from app.api.routes.subscription import router as subscription_router from app.api.routes.titles import router as titles_router from app.api.routes.voices import router as voices_router from app.api.routes.generation_tasks import router as generation_tasks_router @@ -92,3 +93,8 @@ api_router.include_router( prefix="/duplication", tags=["Duplication"], ) +api_router.include_router( + subscription_router, + prefix="/subscription", + tags=["Subscription"], +) diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py new file mode 100644 index 000000000..5cd0c7525 --- /dev/null +++ b/apps/api/app/api/routes/subscription.py @@ -0,0 +1,193 @@ +"""Subscription management API routes.""" +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timezone +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_user_repository +from app.schemas.subscription import ( + BillingRecord, + ChangePlanRequest, + ChangePlanResponse, + SimpleResponse, + SubscriptionInfo, + ToggleAutoRenewRequest, +) +from packages.ports.user_repository import UserRepository + +router = APIRouter() + + +# ============ 配额定义(硬编码,后续可迁移到配置中心) ============ + +PLAN_QUOTAS = { + "free": {"max_projects": 3, "max_storage_gb": 10}, + "standard": {"max_projects": 10, "max_storage_gb": 50}, + "pro": {"max_projects": -1, "max_storage_gb": 100}, + "enterprise": {"max_projects": -1, "max_storage_gb": 1000}, +} + + +# ============ Helper Functions ============ + +def _get_plan_name(plan_id: str) -> str: + """获取套餐显示名称""" + plan_names = { + "free": "体验版", + "standard": "标准版", + "pro": "专业版", + "enterprise": "企业版", + } + return plan_names.get(plan_id, "未知套餐") + + +def _get_plan_price(plan_id: str, billing_cycle: str) -> float: + """获取套餐价格""" + prices = { + ("free", "monthly"): 0, + ("free", "yearly"): 0, + ("standard", "monthly"): 99, + ("standard", "yearly"): 999, + ("pro", "monthly"): 299, + ("pro", "yearly"): 2999, + ("enterprise", "monthly"): 999, + ("enterprise", "yearly"): 9999, + } + return prices.get((plan_id, billing_cycle), 0) + + +def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo: + """构建订阅信息响应""" + now = datetime.now(timezone.utc) + if user.user.subscription_expires_at: + period_end = user.user.subscription_expires_at.isoformat() + period_start = now.isoformat() + else: + period_start = now.isoformat() + period_end = now.isoformat() + + return SubscriptionInfo( + id=f"sub-{user.user.id[:8]}", + plan_id=user.user.subscription_plan or "free", + plan_name=_get_plan_name(user.user.subscription_plan or "free"), + status=user.user.subscription_status or "active", + billing_cycle="monthly", + current_period_start=period_start, + current_period_end=period_end, + amount=_get_plan_price(user.user.subscription_plan or "free", "monthly"), + auto_renew=True, + created_at=user.user.created_at.isoformat() if user.user.created_at else now.isoformat(), + ) + + +# ============ API Endpoints ============ + +@router.get("/current", response_model=SubscriptionInfo) +async def get_current_subscription( + current_user: AuthenticatedUser = Depends(get_current_user), +): + """获取当前订阅信息""" + return _build_subscription_info(current_user) + + +@router.get("/billing-records", response_model=List[BillingRecord]) +async def get_billing_records( + current_user: AuthenticatedUser = Depends(get_current_user), +): + """获取账单记录列表""" + # TODO: 从数据库查询账单记录 + return [] + + +@router.post("/change-plan", response_model=ChangePlanResponse) +async def change_plan( + request: ChangePlanRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + user_repository: UserRepository = Depends(get_user_repository), +): + """变更订阅套餐(升级/降级)""" + # TODO: 接入支付验证(支付宝/微信支付) + valid_plans = {"free", "standard", "pro", "enterprise"} + if request.target_plan_id not in valid_plans: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"无效的套餐ID。支持的套餐: {', '.join(valid_plans)}", + ) + + valid_cycles = {"monthly", "yearly"} + if request.billing_cycle not in valid_cycles: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的计费周期。支持: monthly, yearly", + ) + + user = current_user.user + current_plan = user.subscription_plan or "free" + target_plan = request.target_plan_id + + if current_plan == target_plan: + return ChangePlanResponse( + success=False, + message=f"您已经是 {_get_plan_name(target_plan)}", + ) + + # 通过 dataclasses.replace 创建新实例(不直接修改 dataclass) + quotas = PLAN_QUOTAS.get(target_plan, PLAN_QUOTAS["free"]) + updated_user = replace( + user, + subscription_plan=target_plan, + subscription_status="active", + max_projects=quotas["max_projects"], + max_storage_gb=quotas["max_storage_gb"], + ) + user_repository.save(updated_user) + + # 用更新后的用户构造响应 + refreshed_auth_user = AuthenticatedUser(user=updated_user) + + return ChangePlanResponse( + success=True, + message=f"套餐已成功变更为 {_get_plan_name(target_plan)}", + new_subscription=_build_subscription_info(refreshed_auth_user), + ) + + +@router.post("/cancel", response_model=SimpleResponse) +async def cancel_subscription( + current_user: AuthenticatedUser = Depends(get_current_user), + user_repository: UserRepository = Depends(get_user_repository), +): + """取消订阅""" + user = current_user.user + if user.subscription_plan == "free": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="体验版无需取消", + ) + + updated_user = replace(user, subscription_status="cancelled") + user_repository.save(updated_user) + + return SimpleResponse( + success=True, + message="订阅已取消,当前周期结束后停止服务", + ) + + +@router.post("/toggle-auto-renew", response_model=SimpleResponse) +async def toggle_auto_renew( + request: ToggleAutoRenewRequest, + current_user: AuthenticatedUser = Depends(get_current_user), +): + """切换自动续费""" + # TODO: 实际需要在数据库中存储 auto_renew 字段 + status_text = "已开启自动续费" if request.enabled else "已关闭自动续费" + + return SimpleResponse( + success=True, + message=status_text, + ) diff --git a/apps/api/app/schemas/subscription.py b/apps/api/app/schemas/subscription.py new file mode 100644 index 000000000..8c42d0939 --- /dev/null +++ b/apps/api/app/schemas/subscription.py @@ -0,0 +1,92 @@ +"""Subscription schemas for API request/response models.""" +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# ============ Enums / Types ============ + +class PlanType(str): + """套餐类型""" + FREE = "free" + STANDARD = "standard" + PRO = "pro" + ENTERPRISE = "enterprise" + + +class SubscriptionStatus(str): + """订阅状态""" + ACTIVE = "active" + EXPIRED = "expired" + CANCELLED = "cancelled" + TRIAL = "trial" + + +class BillingStatus(str): + """账单状态""" + PAID = "paid" + PENDING = "pending" + FAILED = "failed" + REFUNDED = "refunded" + + +class BillingCycle(str): + """计费周期""" + MONTHLY = "monthly" + YEARLY = "yearly" + + +# ============ Response Schemas ============ + +class SubscriptionInfo(BaseModel): + """当前订阅信息""" + id: str + plan_id: str + plan_name: str + status: str + billing_cycle: str + current_period_start: str + current_period_end: str + amount: float + auto_renew: bool + created_at: str + + +class BillingRecord(BaseModel): + """账单记录""" + id: str + plan_name: str + amount: float + billing_cycle: str + status: str + payment_method: str + created_at: str + invoice_url: Optional[str] = None + + +class ChangePlanResponse(BaseModel): + """升级/降级响应""" + success: bool + message: str + new_subscription: Optional[SubscriptionInfo] = None + + +class SimpleResponse(BaseModel): + """简单响应(用于取消订阅、切换自动续费等)""" + success: bool + message: str + + +# ============ Request Schemas ============ + +class ChangePlanRequest(BaseModel): + """升级/降级请求""" + target_plan_id: str = Field(..., description="目标套餐ID") + billing_cycle: str = Field(..., description="计费周期: monthly/yearly") + + +class ToggleAutoRenewRequest(BaseModel): + """切换自动续费请求""" + enabled: bool = Field(..., description="是否开启自动续费") diff --git a/packages/adapters/sqlalchemy_impl/user_repository.py b/packages/adapters/sqlalchemy_impl/user_repository.py index 7b7f269a7..ff44c9277 100644 --- a/packages/adapters/sqlalchemy_impl/user_repository.py +++ b/packages/adapters/sqlalchemy_impl/user_repository.py @@ -27,6 +27,11 @@ class SQLAlchemyUserRepository(UserRepository): model.password_reset_expires_at = user.password_reset_expires_at model.last_login_at = user.last_login_at model.last_login_ip = user.last_login_ip + model.subscription_plan = user.subscription_plan + model.subscription_status = user.subscription_status + model.subscription_expires_at = user.subscription_expires_at + model.max_projects = user.max_projects + model.max_storage_gb = user.max_storage_gb model.created_at = user.created_at self.session.commit() @@ -75,5 +80,10 @@ class SQLAlchemyUserRepository(UserRepository): password_reset_expires_at=model.password_reset_expires_at, last_login_at=model.last_login_at, last_login_ip=model.last_login_ip, + subscription_plan=model.subscription_plan or "free", + subscription_status=model.subscription_status or "active", + subscription_expires_at=model.subscription_expires_at, + max_projects=model.max_projects or 3, + max_storage_gb=model.max_storage_gb or 10, created_at=model.created_at, ) diff --git a/requirements.txt b/requirements.txt index 397d7177a..2db7ce5e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ alembic==1.13.3 # 认证 pyjwt==2.9.0 bcrypt==4.2.0 -python-multipart==0.0.12 +python-multipart==0.0.32 # Redis redis==5.2.0