41cd9cdc56
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 39s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m45s
CI/CD Pipeline / Integration Tests (push) Successful in 3m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m28s
CI/CD Pipeline / Validate - Style (push) Successful in 4m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / Validate - Security (push) Successful in 8m42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m24s
CI/CD Pipeline / Unit Tests (push) Successful in 10m40s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Failing after 5h13m27s
CI/CD Pipeline / Build Production API Image (push) Failing after 5h13m27s
CI/CD Pipeline / PR Build Web Image (push) Failing after 5h24m10s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 5h23m17s
CI/CD Pipeline / PR Build API Image (push) Failing after 5h23m18s
CI/CD Pipeline / Deploy Production (push) Failing after 5h12m32s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 5h12m35s
CI/CD Pipeline / Build Production Web Image (push) Failing after 5h12m35s
CI/CD Pipeline / Canary Release to Production (push) Failing after 5h12m32s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 5h22m32s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 5h22m32s
CI/CD Pipeline / Frontend Lint (push) Failing after 5h23m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 5h23m18s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 5h59m5s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
340 lines
11 KiB
Python
340 lines
11 KiB
Python
"""积分 & 会员 API 路由 (#1895)
|
||
|
||
导出两个 router:
|
||
- points_router: 积分相关路由,前缀 /points
|
||
- usage_router: 每日额度路由,前缀 /usage
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Optional
|
||
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.dependencies import get_db_session
|
||
from app.schemas.points import (
|
||
DailyUsageResponse,
|
||
MembershipStatusResponse,
|
||
PointRuleItem,
|
||
PointsBalanceResponse,
|
||
PointsCheckRequest,
|
||
PointsCheckResponse,
|
||
PointsDeductRequest,
|
||
PointsOrderResponse,
|
||
PointsPackageItem,
|
||
PointsPackagesResponse,
|
||
PointsRechargeRequest,
|
||
PointsRefundRequest,
|
||
PointsRulesResponse,
|
||
PointsTransactionsResponse,
|
||
SimpleMessageResponse,
|
||
)
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.domain.points_rules import (
|
||
FREE_USER_MULTIPLIER,
|
||
MEMBER_DISCOUNT,
|
||
POINTS_PACKAGES,
|
||
POINTS_SCENES,
|
||
calculate_points_cost,
|
||
)
|
||
from packages.domain.points_service import PointsService
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── 两个 router ──
|
||
points_router = APIRouter()
|
||
usage_router = APIRouter()
|
||
|
||
|
||
def _get_service() -> PointsService:
|
||
return PointsService()
|
||
|
||
|
||
def _is_member(user: AuthenticatedUser) -> bool:
|
||
"""判断用户是否为付费会员。"""
|
||
return getattr(user.user, "is_member", False)
|
||
|
||
|
||
def _member_type(user: AuthenticatedUser) -> str | None:
|
||
return getattr(user.user, "member_type", None)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════
|
||
# 积分相关路由 (prefix=/points)
|
||
# ════════════════════════════════════════════════════════════════
|
||
|
||
|
||
@points_router.get("/balance", response_model=PointsBalanceResponse)
|
||
def get_balance(
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""查询当前用户积分余额 + 会员状态。"""
|
||
svc = _get_service()
|
||
account = svc.get_or_create_account(current_user.user.id, db)
|
||
return PointsBalanceResponse(
|
||
balance=account["balance"],
|
||
total_earned=account["total_earned"],
|
||
total_spent=account["total_spent"],
|
||
is_member=_is_member(current_user),
|
||
member_type=_member_type(current_user),
|
||
member_expires_at=getattr(current_user.user, "member_expires_at", None),
|
||
)
|
||
|
||
|
||
@points_router.get("/transactions", response_model=PointsTransactionsResponse)
|
||
def get_transactions(
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
type: Optional[str] = Query(None, description="筛选类型: add/deduct"),
|
||
source: Optional[str] = Query(None, description="筛选来源场景"),
|
||
start_date: Optional[datetime] = Query(None),
|
||
end_date: Optional[datetime] = Query(None),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""查询积分流水(分页+筛选)。"""
|
||
svc = _get_service()
|
||
result = svc.get_transactions(
|
||
user_id=current_user.user.id,
|
||
db=db,
|
||
page=page,
|
||
page_size=page_size,
|
||
type_filter=type,
|
||
source_filter=source,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
)
|
||
return PointsTransactionsResponse(**result)
|
||
|
||
|
||
@points_router.get("/rules", response_model=PointsRulesResponse)
|
||
def get_rules(
|
||
_current_user: AuthenticatedUser = Depends(get_current_user),
|
||
):
|
||
"""查询所有积分消耗规则。"""
|
||
rules = []
|
||
for scene_key, scene_data in POINTS_SCENES.items():
|
||
rules.append(
|
||
PointRuleItem(
|
||
scene_key=scene_key,
|
||
name=scene_data["name"],
|
||
base_points=scene_data["base_points"],
|
||
unit=scene_data["unit"],
|
||
extra_per_30s=scene_data.get("extra_per_30s"),
|
||
description=scene_data.get("description", ""),
|
||
)
|
||
)
|
||
return PointsRulesResponse(
|
||
rules=rules,
|
||
free_user_multiplier=FREE_USER_MULTIPLIER,
|
||
)
|
||
|
||
|
||
@points_router.get("/packages", response_model=PointsPackagesResponse)
|
||
def get_packages(
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
):
|
||
"""查询可购买的积分包列表。"""
|
||
packages = []
|
||
for code, pkg in POINTS_PACKAGES.items():
|
||
unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分"
|
||
packages.append(
|
||
PointsPackageItem(
|
||
code=code,
|
||
name=pkg["name"],
|
||
points=pkg["points"],
|
||
price_cents=pkg["price_cents"],
|
||
unit_price=unit_price,
|
||
)
|
||
)
|
||
mt = _member_type(current_user)
|
||
discount = MEMBER_DISCOUNT.get(mt) if mt else None
|
||
return PointsPackagesResponse(packages=packages, user_discount=discount)
|
||
|
||
|
||
@points_router.post("/check", response_model=PointsCheckResponse)
|
||
def check_points(
|
||
body: PointsCheckRequest,
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。"""
|
||
if body.scene_key not in POINTS_SCENES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"code": "UNKNOWN_SCENE",
|
||
"message": f"未知场景: {body.scene_key}",
|
||
"valid_scenes": sorted(POINTS_SCENES.keys()),
|
||
},
|
||
)
|
||
is_mem = _is_member(current_user)
|
||
mt = _member_type(current_user)
|
||
|
||
# 混剪场景先检查免费额度
|
||
is_free_quota = False
|
||
if body.scene_key == "ai_video" and not is_mem:
|
||
svc = _get_service()
|
||
if svc.check_daily_free_clip(current_user.user.id, db):
|
||
is_free_quota = True
|
||
|
||
required = calculate_points_cost(
|
||
body.scene_key,
|
||
is_mem,
|
||
quantity=body.quantity or 1,
|
||
duration_minutes=body.duration_minutes or 0,
|
||
member_type=mt,
|
||
)
|
||
|
||
svc = _get_service()
|
||
account = svc.get_or_create_account(current_user.user.id, db)
|
||
balance = account["balance"]
|
||
|
||
return PointsCheckResponse(
|
||
allowed=is_free_quota or balance >= required,
|
||
required_points=required,
|
||
current_balance=balance,
|
||
remaining_after=balance - required,
|
||
is_free_quota=is_free_quota,
|
||
)
|
||
|
||
|
||
@points_router.post("/deduct", response_model=SimpleMessageResponse)
|
||
def deduct_points(
|
||
body: PointsDeductRequest,
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""积分扣减(内部服务调用)。"""
|
||
svc = _get_service()
|
||
result = svc.deduct_points(
|
||
user_id=current_user.user.id,
|
||
amount=body.amount,
|
||
source=body.scene_key,
|
||
db=db,
|
||
description=body.description or "",
|
||
ref_id=body.ref_id or "",
|
||
)
|
||
if not result["success"]:
|
||
raise HTTPException(
|
||
status_code=402,
|
||
detail={
|
||
"code": "INSUFFICIENT_POINTS",
|
||
"message": f"积分不足,需要 {body.amount},余额 {result['balance']}",
|
||
},
|
||
)
|
||
return SimpleMessageResponse(
|
||
success=True,
|
||
message=f"扣减 {body.amount} 积分成功",
|
||
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
|
||
)
|
||
|
||
|
||
@points_router.post("/refund", response_model=SimpleMessageResponse)
|
||
def refund_points(
|
||
body: PointsRefundRequest,
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""积分退还(内部服务调用)。"""
|
||
from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel
|
||
|
||
txn = (
|
||
db.query(PointsTransactionModel)
|
||
.filter(PointsTransactionModel.id == body.transaction_id)
|
||
.first()
|
||
)
|
||
if txn is None:
|
||
raise HTTPException(status_code=404, detail="交易记录不存在")
|
||
if txn.user_id != current_user.user.id:
|
||
raise HTTPException(status_code=403, detail="无权退还他人积分")
|
||
|
||
svc = _get_service()
|
||
result = svc.refund_points(
|
||
user_id=current_user.user.id,
|
||
amount=txn.amount,
|
||
source=txn.source,
|
||
db=db,
|
||
ref_id=body.transaction_id,
|
||
description=body.reason or f"退还: {txn.description}",
|
||
)
|
||
if not result["success"]:
|
||
raise HTTPException(status_code=500, detail="退还失败")
|
||
return SimpleMessageResponse(
|
||
success=True,
|
||
message=f"退还 {txn.amount} 积分成功",
|
||
data={"transaction_id": result["transaction_id"], "balance": result["balance"]},
|
||
)
|
||
|
||
|
||
@points_router.post("/recharge", response_model=PointsOrderResponse)
|
||
def create_recharge_order(
|
||
body: PointsRechargeRequest,
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""创建积分充值订单。pay_params 在支付通道接入后填入 prepay_id/payment_url;当前为空 dict。"""
|
||
svc = _get_service()
|
||
try:
|
||
order = svc.create_order(
|
||
user_id=current_user.user.id,
|
||
order_type="points",
|
||
product_code=body.package_id,
|
||
db=db,
|
||
)
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||
|
||
package = POINTS_PACKAGES.get(body.package_id, {})
|
||
now = datetime.now(timezone.utc)
|
||
expire_at = now + timedelta(hours=48)
|
||
# TODO: 接入微信/支付宝后填充真实 prepay_id / payment_url
|
||
order["points_amount"] = package.get("points", 0)
|
||
order["pay_params"] = {}
|
||
order["expire_at"] = expire_at.isoformat()
|
||
return PointsOrderResponse(**order)
|
||
|
||
|
||
@points_router.get("/subscription/membership", response_model=MembershipStatusResponse)
|
||
def get_membership_status(
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""获取当前用户会员状态(聚合信息)。"""
|
||
svc = _get_service()
|
||
account = svc.get_or_create_account(current_user.user.id, db)
|
||
is_mem = _is_member(current_user)
|
||
max_resolution = "1080p" if is_mem else "720p"
|
||
|
||
return MembershipStatusResponse(
|
||
is_member=is_mem,
|
||
member_type=_member_type(current_user),
|
||
member_expires_at=getattr(current_user.user, "member_expires_at", None),
|
||
points_balance=account["balance"],
|
||
max_resolution=max_resolution,
|
||
)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════
|
||
# 每日额度路由 (prefix=/usage)
|
||
# ════════════════════════════════════════════════════════════════
|
||
|
||
|
||
@usage_router.get("/daily", response_model=DailyUsageResponse)
|
||
def get_daily_usage(
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
db: Session = Depends(get_db_session),
|
||
):
|
||
"""查询今日免费混剪额度使用情况。"""
|
||
svc = _get_service()
|
||
result = svc.get_daily_usage(current_user.user.id, db)
|
||
return DailyUsageResponse(**result)
|
||
|
||
|
||
# 为了向后兼容,也导出一个不带后缀的 router(方便旧引用)
|
||
router = points_router
|