c9b9fbc397
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 133h46m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 133h46m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 133h46m39s
197 lines
6.2 KiB
Python
197 lines
6.2 KiB
Python
"""Subscription management API routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from datetime import datetime, timezone
|
|
from typing import List
|
|
|
|
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 fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
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,
|
|
)
|