67a1ed6430
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m3s
CI/CD Pipeline / Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m40s
CI/CD Pipeline / Validate - Style (push) Successful in 3m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m17s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m6s
CI/CD Pipeline / Validate - Security (push) Successful in 7m14s
CI/CD Pipeline / Unit Tests (push) Successful in 9m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 35m54s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
297 lines
10 KiB
Python
Executable File
297 lines
10 KiB
Python
Executable File
"""Subscription management API routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from app.auth import AuthenticatedUser, get_current_user
|
|
from app.dependencies import get_user_repository
|
|
from app.schemas.subscription import (
|
|
BillingCycle,
|
|
BillingRecord,
|
|
ChangePlanRequest,
|
|
ChangePlanResponse,
|
|
MembershipType,
|
|
SimpleResponse,
|
|
SubscriptionInfo,
|
|
ToggleAutoRenewRequest,
|
|
)
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
from packages.ports.user_repository import UserRepository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ============ 会员展示名称(与 packages.domain.points_rules.MEMBERSHIP_PRICES 对应)============
|
|
|
|
_PLAN_NAMES: dict[str, str] = {
|
|
MembershipType.FREE: "免费用户",
|
|
MembershipType.MONTHLY: "月卡会员",
|
|
MembershipType.QUARTERLY: "季卡会员",
|
|
MembershipType.YEARLY: "年卡会员",
|
|
}
|
|
|
|
|
|
def _get_plan_name(plan_id: str) -> str:
|
|
return _PLAN_NAMES.get(plan_id, "免费用户")
|
|
|
|
|
|
def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
|
"""构建订阅信息响应"""
|
|
now = datetime.now(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()
|
|
|
|
plan_id = user.user.subscription_plan or MembershipType.FREE
|
|
# 旧档位(standard/pro/enterprise)统一降级为 monthly,避免前端炸掉
|
|
if plan_id in {"standard", "pro", "enterprise"}:
|
|
plan_id = MembershipType.MONTHLY
|
|
|
|
return SubscriptionInfo(
|
|
id=f"sub-{user.user.id[:8]}",
|
|
plan_id=plan_id,
|
|
plan_name=_get_plan_name(plan_id),
|
|
status=user.user.subscription_status or "active",
|
|
billing_cycle=plan_id if plan_id != MembershipType.FREE else BillingCycle.MONTHLY,
|
|
current_period_start=period_start,
|
|
current_period_end=period_end,
|
|
amount=0 if plan_id == MembershipType.FREE else 0, # 金额由前端 /plans 接口展示
|
|
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("/plans")
|
|
def list_membership_plans(
|
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
"""查询所有会员档位(供前端会员购买页展示)。
|
|
|
|
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
|
|
"""
|
|
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
|
|
|
|
plans: list[dict[str, Any]] = []
|
|
for plan_id, info in MEMBERSHIP_PRICES.items():
|
|
days = info["duration_days"]
|
|
monthly_cents = round(info["price_cents"] * 30 / days)
|
|
features: dict[str, Any] = {"max_resolution": "1080p"}
|
|
if plan_id == MembershipType.MONTHLY:
|
|
features.update({"free_clips_daily": 2})
|
|
elif plan_id == MembershipType.QUARTERLY:
|
|
features.update({"free_clips_daily": 5})
|
|
elif plan_id == MembershipType.YEARLY:
|
|
features.update({"free_clips_daily": "unlimited"})
|
|
plans.append({
|
|
"plan_id": plan_id,
|
|
"name": info["name"],
|
|
"price_cents": info["price_cents"],
|
|
"monthly_price_cents": monthly_cents,
|
|
"duration_days": days,
|
|
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
|
|
"features": features,
|
|
})
|
|
return {"plans": plans}
|
|
|
|
|
|
@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=_get_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()
|
|
|
|
|
|
_VALID_PLANS = {MembershipType.MONTHLY, MembershipType.QUARTERLY, MembershipType.YEARLY}
|
|
_VALID_CYCLES = {BillingCycle.MONTHLY, BillingCycle.QUARTERLY, BillingCycle.YEARLY}
|
|
|
|
|
|
@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: 接入支付验证(支付宝/微信支付)
|
|
target_plan = request.target_plan_id
|
|
if target_plan not in _VALID_PLANS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"无效的会员类型。支持: {', '.join(sorted(_VALID_PLANS))}",
|
|
)
|
|
|
|
if request.billing_cycle not in _VALID_CYCLES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"无效的计费周期。支持: {', '.join(sorted(_VALID_CYCLES))}",
|
|
)
|
|
|
|
user = current_user.user
|
|
current_plan = user.subscription_plan or MembershipType.FREE
|
|
# 旧档位归一化,避免永远显示"您已经是xxx"
|
|
if current_plan in {"standard", "pro", "enterprise"}:
|
|
current_plan = MembershipType.MONTHLY
|
|
|
|
if current_plan == target_plan:
|
|
return ChangePlanResponse(
|
|
success=False,
|
|
message=f"您已经是{_get_plan_name(target_plan)}",
|
|
)
|
|
|
|
updated_user = replace(
|
|
user,
|
|
subscription_plan=target_plan,
|
|
subscription_status="active",
|
|
max_projects=-1, # 付费会员不限项目数
|
|
max_storage_gb=100,
|
|
)
|
|
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
|
|
plan_id = user.subscription_plan or MembershipType.FREE
|
|
if plan_id == MembershipType.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")
|
|
|
|
# 仅接受当前会员体系的 plan 值
|
|
if plan not in _VALID_PLANS:
|
|
raise HTTPException(status_code=400, detail=f"未知的会员类型: {plan}")
|
|
|
|
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_map = {BillingCycle.MONTHLY: 30, BillingCycle.QUARTERLY: 90, BillingCycle.YEARLY: 365}
|
|
days = days_map.get(billing_cycle, 30)
|
|
expires_at = datetime.now(UTC) + timedelta(days=days)
|
|
repo.update_subscription_on_payment(user_id, plan, expires_at)
|
|
|
|
return {"success": True, "message": "支付成功", "record_id": record_id}
|
|
except HTTPException:
|
|
session.rollback()
|
|
raise
|
|
except Exception as e:
|
|
session.rollback()
|
|
logger.error("支付回调处理失败: user_id=%s, plan=%s, error=%s", user_id, plan, 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:
|
|
"""切换自动续费"""
|
|
status_text = "已开启自动续费" if request.enabled else "已关闭自动续费"
|
|
return SimpleResponse(success=True, message=status_text)
|