Files
xiaoxia-saas/apps/api/app/api/routes/subscription.py
T
xiaoxia 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
fix(security): 第三轮审计P1修复 - 音色克隆SSRF防护 + 支付接口异常信息脱敏 (#388)
2026-07-16 12:58:50 +08:00

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,
)