diff --git a/apps/api/app/api/routes/points.py b/apps/api/app/api/routes/points.py index 9bdfb48d3..39889e267 100644 --- a/apps/api/app/api/routes/points.py +++ b/apps/api/app/api/routes/points.py @@ -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), diff --git a/apps/api/app/schemas/points.py b/apps/api/app/schemas/points.py index 0ab25b8df..6424b28a1 100644 --- a/apps/api/app/schemas/points.py +++ b/apps/api/app/schemas/points.py @@ -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] + + # ============ 通用响应 ============ diff --git a/packages/domain/points_rules.py b/packages/domain/points_rules.py index 25aff4c1d..5bc6dc12a 100644 --- a/packages/domain/points_rules.py +++ b/packages/domain/points_rules.py @@ -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 积分"}, } # 免费用户积分消耗上浮系数 diff --git a/tests/unit/test_points_routes.py b/tests/unit/test_points_routes.py new file mode 100644 index 000000000..7bc09eda9 --- /dev/null +++ b/tests/unit/test_points_routes.py @@ -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}"