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..b480e4ad3 --- /dev/null +++ b/apps/api/app/api/routes/subscription.py @@ -0,0 +1,194 @@ +"""Subscription management API routes.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.auth import AuthenticatedUser, get_current_user +from app.database import get_db +from app.schemas.subscription import ( + BillingRecord, + ChangePlanRequest, + ChangePlanResponse, + SimpleResponse, + SubscriptionInfo, + ToggleAutoRenewRequest, +) + +router = APIRouter() + + +# ============ 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), + db: Session = Depends(get_db), +): + """获取当前订阅信息""" + 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), + db: Session = Depends(get_db), +): + """获取账单记录列表""" + # TODO: 从数据库查询账单记录 + # 目前返回空列表,后续实现账单系统 + return [] + + +@router.post("/change-plan", response_model=ChangePlanResponse) +async def change_plan( + request: ChangePlanRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """变更订阅套餐(升级/降级)""" + # 验证目标套餐 + 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)}", + ) + + # 更新用户订阅信息 + # TODO: 实际支付流程需要集成支付系统 + user.subscription_plan = target_plan + user.subscription_status = "active" + + # 更新配额限制 + from packages.infrastructure.quota_registry import quota_registry + quotas = quota_registry.get_plan_quotas(target_plan) + if "max_projects" in quotas: + user.max_projects = quotas["max_projects"] + if "max_storage_gb" in quotas: + user.max_storage_gb = quotas["max_storage_gb"] + + db.commit() + db.refresh(user) + + return ChangePlanResponse( + success=True, + message=f"套餐已成功变更为 {_get_plan_name(target_plan)}", + new_subscription=_build_subscription_info(current_user), + ) + + +@router.post("/cancel", response_model=SimpleResponse) +async def cancel_subscription( + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db), +): + """取消订阅""" + user = current_user.user + if user.subscription_plan == "free": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="体验版无需取消", + ) + + # 标记为取消状态(当前周期结束后停止服务) + user.subscription_status = "cancelled" + db.commit() + + 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), + db: Session = Depends(get_db), +): + """切换自动续费""" + # 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="是否开启自动续费")