feat: implement Phase 2 subscription management API
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled

Implement 5 subscription endpoints matching frontend subscription.ts:

API Endpoints:
- GET /api/v1/subscription/current — get current subscription info
- GET /api/v1/subscription/billing-records — get billing history
- POST /api/v1/subscription/change-plan — upgrade/downgrade plan
- POST /api/v1/subscription/cancel — cancel subscription
- POST /api/v1/subscription/toggle-auto-renew — toggle auto-renewal

Implementation Details:
- Created subscription schemas (SubscriptionInfo, BillingRecord, etc.)
- Created subscription routes with proper authentication
- Integrated with quota_registry for plan limits
- Added plan validation (free/standard/pro/enterprise)
- Added billing cycle validation (monthly/yearly)
- Registered subscription router in api_router

Note: Billing records currently returns empty list (TODO: implement billing system)
Auto-renew toggle is simulated (TODO: add auto_renew field to User model)

All endpoints follow existing patterns from titles/voices/duplication APIs.
This commit is contained in:
API文档维护Agent
2026-06-28 17:40:59 +08:00
parent 241426294b
commit 27303e34eb
3 changed files with 292 additions and 0 deletions
+6
View File
@@ -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"],
)
+194
View File
@@ -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,
)
+92
View File
@@ -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="是否开启自动续费")