061b9cfcd6
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 6m58s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 2m31s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m24s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
280 lines
9.0 KiB
Python
Executable File
280 lines
9.0 KiB
Python
Executable File
"""Subscription management API routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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),
|
|
) -> SubscriptionInfo:
|
|
"""获取当前订阅信息"""
|
|
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),
|
|
) -> List[BillingRecord]:
|
|
"""获取账单记录列表"""
|
|
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
|
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
|
|
|
if SessionLocal is None:
|
|
return []
|
|
|
|
session = SessionLocal()
|
|
try:
|
|
repo = SQLAlchemyBillingRepository(session)
|
|
records = repo.find_by_user(current_user.user.id)
|
|
return [
|
|
BillingRecord(
|
|
id=r.id,
|
|
plan_name=r.plan_name,
|
|
amount=r.amount,
|
|
billing_cycle=r.billing_cycle,
|
|
status=r.status,
|
|
payment_method=r.payment_method or "未支付",
|
|
created_at=r.created_at.isoformat() if r.created_at else "",
|
|
invoice_url=r.invoice_url,
|
|
)
|
|
for r in records
|
|
]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@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),
|
|
) -> ChangePlanResponse:
|
|
"""变更订阅套餐(升级/降级)"""
|
|
# 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),
|
|
) -> SimpleResponse:
|
|
"""取消订阅"""
|
|
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("/payment-callback")
|
|
async def payment_callback(
|
|
user_id: str,
|
|
plan: str,
|
|
billing_cycle: str,
|
|
amount: float,
|
|
payment_method: str = "alipay",
|
|
payment_id: str = "",
|
|
) -> dict:
|
|
"""支付回调 - 在事务中更新账单和订阅状态
|
|
|
|
注意:生产环境需要验证支付签名
|
|
"""
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository
|
|
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
|
|
|
if SessionLocal is None:
|
|
raise HTTPException(status_code=500, detail="Database not available")
|
|
|
|
session = SessionLocal()
|
|
try:
|
|
repo = SQLAlchemyBillingRepository(session)
|
|
|
|
# 创建账单记录
|
|
record_id = uuid.uuid4().hex
|
|
repo.create(
|
|
{
|
|
"id": record_id,
|
|
"user_id": user_id,
|
|
"plan_name": _get_plan_name(plan),
|
|
"amount": amount,
|
|
"billing_cycle": billing_cycle,
|
|
"status": "pending",
|
|
}
|
|
)
|
|
|
|
# 在事务中标记支付成功并更新订阅
|
|
repo.mark_paid(record_id, payment_method, payment_id)
|
|
|
|
# 计算到期时间
|
|
days = 365 if billing_cycle == "yearly" else 30
|
|
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
|
repo.update_subscription_on_payment(user_id, plan, expires_at)
|
|
|
|
return {"success": True, "message": "支付成功", "record_id": record_id}
|
|
except Exception as e:
|
|
session.rollback()
|
|
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
|
|
# 不返回原始异常信息,避免泄漏内部实现细节
|
|
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@router.post("/toggle-auto-renew", response_model=SimpleResponse)
|
|
async def toggle_auto_renew(
|
|
request: ToggleAutoRenewRequest,
|
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
) -> SimpleResponse:
|
|
"""切换自动续费"""
|
|
# TODO: 实际需要在数据库中存储 auto_renew 字段
|
|
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
|
|
|
|
return SimpleResponse(
|
|
success=True,
|
|
message=status_text,
|
|
)
|