fix(#1895): points API 字段对齐(P0 修复 + P1 补全) #1951
@@ -8,13 +8,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.points import (
|
||||
DailyUsageResponse,
|
||||
MembershipPlanItem,
|
||||
MembershipPlansResponse,
|
||||
MembershipStatusResponse,
|
||||
PointRuleItem,
|
||||
PointsBalanceResponse,
|
||||
@@ -36,6 +38,7 @@ from sqlalchemy.orm import Session
|
||||
from packages.domain.points_rules import (
|
||||
FREE_USER_MULTIPLIER,
|
||||
MEMBER_DISCOUNT,
|
||||
MEMBERSHIP_PRICES,
|
||||
POINTS_PACKAGES,
|
||||
POINTS_SCENES,
|
||||
calculate_points_cost,
|
||||
@@ -125,6 +128,7 @@ def get_rules(
|
||||
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(
|
||||
@@ -161,7 +165,16 @@ def check_points(
|
||||
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)
|
||||
|
||||
@@ -267,7 +280,7 @@ def create_recharge_order(
|
||||
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(
|
||||
@@ -278,9 +291,47 @@ def create_recharge_order(
|
||||
)
|
||||
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/plans", response_model=MembershipPlansResponse)
|
||||
def list_membership_plans(
|
||||
_current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询所有会员档位(名称/价格/权益),供前端会员购买页展示。"""
|
||||
plans: list[MembershipPlanItem] = []
|
||||
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 == "monthly":
|
||||
features.update({"free_clips_daily": 2})
|
||||
elif plan_id == "quarterly":
|
||||
features.update({"free_clips_daily": 5})
|
||||
elif plan_id == "yearly":
|
||||
features.update({"free_clips_daily": "unlimited"})
|
||||
plans.append(
|
||||
MembershipPlanItem(
|
||||
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 MembershipPlansResponse(plans=plans)
|
||||
|
||||
|
||||
@points_router.get("/subscription/membership", response_model=MembershipStatusResponse)
|
||||
def get_membership_status(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -57,6 +57,7 @@ class PointRuleItem(BaseModel):
|
||||
base_points: int
|
||||
unit: str
|
||||
extra_per_30s: Optional[int] = None
|
||||
description: str = Field(default="", description="规则中文说明,例如 AI 配音每分钟消耗 X 积分")
|
||||
|
||||
|
||||
class PointsRulesResponse(BaseModel):
|
||||
@@ -139,7 +140,12 @@ class PointsOrderResponse(BaseModel):
|
||||
order_type: str
|
||||
product_code: str
|
||||
amount_cents: int
|
||||
points_amount: int = Field(0, description="本次充值/购买可获得的积分(仅 points 类型订单有意义)")
|
||||
status: str
|
||||
pay_params: dict[str, Any] = Field(
|
||||
default_factory=dict, description="拉起支付所需参数(payment_url/prepay_id 等),支付通道接入后填充"
|
||||
)
|
||||
expire_at: Optional[str] = Field(None, description="订单过期时间(ISO 8601),默认创建后 48 小时")
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
@@ -171,6 +177,27 @@ class MembershipStatusResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# ============ 订阅档位 ============
|
||||
|
||||
|
||||
class MembershipPlanItem(BaseModel):
|
||||
"""单个会员档位"""
|
||||
|
||||
plan_id: str = Field(..., description="档位标识: monthly/quarterly/yearly")
|
||||
name: str = Field(..., description="档位名称,例如 月卡")
|
||||
monthly_price_cents: int = Field(..., description="折算月价(分)")
|
||||
price_cents: int = Field(..., description="该档位总价(分)")
|
||||
duration_days: int = Field(..., description="时长(天)")
|
||||
points_discount: float = Field(..., description="该档位积分折扣,如 0.9 表示 9 折")
|
||||
features: dict[str, Any] = Field(default_factory=dict, description="档位权益(max_resolution 等)")
|
||||
|
||||
|
||||
class MembershipPlansResponse(BaseModel):
|
||||
"""所有会员档位列表"""
|
||||
|
||||
plans: list[MembershipPlanItem]
|
||||
|
||||
|
||||
# ============ 通用响应 ============
|
||||
|
||||
|
||||
|
||||
@@ -8,20 +8,51 @@ import math
|
||||
# 每个场景: base_points(基础积分), unit(计费单位), name(显示名称)
|
||||
|
||||
POINTS_SCENES: dict[str, dict] = {
|
||||
"ai_voice": {"base_points": 1, "unit": "分钟", "name": "AI 配音"},
|
||||
"ai_voice": {
|
||||
"base_points": 1,
|
||||
"unit": "分钟",
|
||||
"name": "AI 配音",
|
||||
"description": "AI 配音每分钟消耗 1 积分(免费用户上浮 15%,会员 8~9 折)",
|
||||
},
|
||||
"ai_video": {
|
||||
"base_points": 3,
|
||||
"unit": "条",
|
||||
"name": "智能混剪",
|
||||
"extra_per_30s": 1,
|
||||
"description": "智能混剪每条 3 积分起,视频超过 30 秒后每 30 秒加 1 积分;免费用户每日 2 条免费额度",
|
||||
},
|
||||
"ai_digital_human": {"base_points": 15, "unit": "分钟", "name": "AI 数字人"},
|
||||
"voice_clone_train": {"base_points": 0, "unit": "次", "name": "声音克隆训练"},
|
||||
"voice_clone_synth": {"base_points": 1, "unit": "分钟", "name": "声音克隆合成"},
|
||||
"douyin_extract": {"base_points": 1, "unit": "次", "name": "抖音链接提取"},
|
||||
"ai_rewrite": {"base_points": 1, "unit": "次", "name": "AI 改写文案"},
|
||||
"ai_title": {"base_points": 1, "unit": "次", "name": "AI 标题生成"},
|
||||
"ai_cover": {"base_points": 1, "unit": "张", "name": "AI 封面生成"},
|
||||
"ai_digital_human": {
|
||||
"base_points": 15,
|
||||
"unit": "分钟",
|
||||
"name": "AI 数字人",
|
||||
"description": "AI 数字人每分钟消耗 15 积分",
|
||||
},
|
||||
"voice_clone_train": {
|
||||
"base_points": 0,
|
||||
"unit": "次",
|
||||
"name": "声音克隆训练",
|
||||
"description": "声音克隆训练免费(每用户限 1 个声音)",
|
||||
},
|
||||
"voice_clone_synth": {
|
||||
"base_points": 1,
|
||||
"unit": "分钟",
|
||||
"name": "声音克隆合成",
|
||||
"description": "克隆音色合成每分钟消耗 1 积分",
|
||||
},
|
||||
"douyin_extract": {
|
||||
"base_points": 1,
|
||||
"unit": "次",
|
||||
"name": "抖音链接提取",
|
||||
"description": "抖音文案提取每次 1 积分",
|
||||
},
|
||||
"ai_rewrite": {"base_points": 1, "unit": "次", "name": "AI 改写文案", "description": "AI 改写文案每次 1 积分"},
|
||||
"ai_title": {
|
||||
"base_points": 1,
|
||||
"unit": "次",
|
||||
"name": "AI 标题生成",
|
||||
"description": "AI 生成标题每次 1 积分(免费用户实际上浮后 2 积分/次)",
|
||||
},
|
||||
"ai_cover": {"base_points": 1, "unit": "张", "name": "AI 封面生成", "description": "AI 封面生成每张 1 积分"},
|
||||
}
|
||||
|
||||
# 免费用户积分消耗上浮系数
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""积分/会员 API 路由对齐测试 — fix/1895-points-api-align
|
||||
|
||||
覆盖:
|
||||
- P0-1: POST /points/recharge 返回 pay_params / points_amount / expire_at
|
||||
- P0-2: POST /points/check 未知 scene_key 返回 400(非 500)
|
||||
- P1-3: GET /points/rules 返回 description 字段
|
||||
- P1-6: GET /subscription/plans 返回档位列表
|
||||
- P1-7: multiplier 实际扣费一致(calculate_points_cost 统一应用)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _make_cu(user_id="user-1", is_member=False, member_type=None):
|
||||
cu = MagicMock()
|
||||
cu.user.id = user_id
|
||||
cu.user.is_member = is_member
|
||||
cu.user.member_type = member_type
|
||||
cu.user.member_expires_at = None
|
||||
return cu
|
||||
|
||||
|
||||
# ── P0-1: recharge response fields ────────────────────────────────────
|
||||
|
||||
|
||||
class TestRechargeOrderResponse:
|
||||
def test_recharge_returns_pay_params_points_amount_expire_at(self):
|
||||
"""recharge 响应必须包含 pay_params / points_amount / expire_at。"""
|
||||
from app.api.routes.points import create_recharge_order
|
||||
from app.schemas.points import PointsRechargeRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.create_order.return_value = {
|
||||
"id": "order-1",
|
||||
"order_type": "points",
|
||||
"product_code": "starter_pack",
|
||||
"amount_cents": 990,
|
||||
"status": "pending",
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsRechargeRequest(package_id="starter_pack")
|
||||
|
||||
before = datetime.now(UTC)
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = create_recharge_order(body=body, current_user=cu, db=db)
|
||||
after = datetime.now(UTC) + timedelta(hours=48)
|
||||
|
||||
assert resp.points_amount == 100 # starter_pack 100 分
|
||||
assert isinstance(resp.pay_params, dict)
|
||||
assert resp.expire_at is not None
|
||||
expire_dt = datetime.fromisoformat(resp.expire_at)
|
||||
assert expire_dt >= before + timedelta(hours=47, minutes=55)
|
||||
assert expire_dt <= after
|
||||
|
||||
def test_recharge_invalid_package_returns_400(self):
|
||||
from app.api.routes.points import create_recharge_order
|
||||
from app.schemas.points import PointsRechargeRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.create_order.side_effect = ValueError("invalid package")
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsRechargeRequest(package_id="nonexistent")
|
||||
|
||||
with pytest.raises(HTTPException) as exc, patch(
|
||||
"app.api.routes.points._get_service", return_value=svc
|
||||
):
|
||||
create_recharge_order(body=body, current_user=cu, db=db)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── P0-2: check unknown scene → 400 ───────────────────────────────────
|
||||
|
||||
|
||||
class TestCheckPointsUnknownScene:
|
||||
def test_unknown_scene_returns_400_not_500(self):
|
||||
"""未知 scene_key(如 ai_script)应返回 400 UNKNOWN_SCENE,而不是 500。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="ai_script", quantity=1)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_points(body=body, current_user=cu, db=db)
|
||||
assert exc.value.status_code == 400
|
||||
detail = exc.value.detail
|
||||
assert detail["code"] == "UNKNOWN_SCENE"
|
||||
assert "ai_script" in detail["message"]
|
||||
assert "ai_voice" in detail["valid_scenes"]
|
||||
assert "ai_title" in detail["valid_scenes"]
|
||||
|
||||
def test_known_scene_still_works(self):
|
||||
"""合法 scene_key 正常返回,免费用户 ai_voice 1 分钟 = 2 积分。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 50}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1)
|
||||
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
assert resp.required_points == 2 # ceil(1 * 1.15) = 2
|
||||
assert resp.current_balance == 50
|
||||
assert resp.allowed is True
|
||||
|
||||
|
||||
# ── P1-3: rules include description ───────────────────────────────────
|
||||
|
||||
|
||||
class TestPointsRulesDescription:
|
||||
def test_rules_have_description_field(self):
|
||||
from app.api.routes.points import get_rules
|
||||
|
||||
resp = get_rules(_current_user=_make_cu())
|
||||
assert len(resp.rules) >= 9
|
||||
for rule in resp.rules:
|
||||
assert rule.description, f"{rule.scene_key} missing description"
|
||||
assert isinstance(rule.description, str)
|
||||
assert len(rule.description) > 0
|
||||
|
||||
def test_free_user_multiplier_returned(self):
|
||||
from app.api.routes.points import get_rules
|
||||
|
||||
resp = get_rules(_current_user=_make_cu())
|
||||
assert resp.free_user_multiplier == 1.15
|
||||
|
||||
|
||||
# ── P1-6: GET /subscription/plans ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubscriptionPlans:
|
||||
def test_plans_endpoint_returns_three_tiers(self):
|
||||
from app.api.routes.points import list_membership_plans
|
||||
|
||||
resp = list_membership_plans(_current_user=_make_cu())
|
||||
plan_ids = {p.plan_id for p in resp.plans}
|
||||
assert plan_ids == {"monthly", "quarterly", "yearly"}
|
||||
for p in resp.plans:
|
||||
assert p.price_cents > 0
|
||||
assert p.duration_days in (30, 90, 365)
|
||||
assert 0 < p.points_discount <= 1.0
|
||||
assert "max_resolution" in p.features
|
||||
|
||||
def test_longer_plans_cheaper_per_month(self):
|
||||
from app.api.routes.points import list_membership_plans
|
||||
|
||||
resp = list_membership_plans(_current_user=_make_cu())
|
||||
monthly = next(p for p in resp.plans if p.plan_id == "monthly")
|
||||
quarterly = next(p for p in resp.plans if p.plan_id == "quarterly")
|
||||
yearly = next(p for p in resp.plans if p.plan_id == "yearly")
|
||||
assert monthly.monthly_price_cents == 1990
|
||||
assert quarterly.monthly_price_cents < monthly.monthly_price_cents
|
||||
assert yearly.monthly_price_cents < quarterly.monthly_price_cents
|
||||
|
||||
|
||||
# ── P1-7: multiplier consistency ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestMultiplierConsistency:
|
||||
def test_free_user_ai_title_costs_2(self):
|
||||
"""ai_title base=1,免费用户 ceil(1*1.15)=2。"""
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_title", is_member=False, quantity=1) == 2
|
||||
|
||||
def test_check_matches_direct_calculation(self):
|
||||
"""check 端点 required_points 与 calculate_points_cost 结果一致。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 999}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
|
||||
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
expected = calculate_points_cost(scene, is_member=False, quantity=1)
|
||||
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
|
||||
Reference in New Issue
Block a user