fix(#1895): move /subscription/plans to correct router (fix 404)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 5h38m51s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 5h39m4s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 5h38m4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 5h38m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 5h38m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 5h38m11s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 5h38m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 5h37m58s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 5h38m4s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 5h38m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 5h37m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 5h38m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 5h38m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 6h14m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 5h38m51s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 5h39m4s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 5h38m4s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m20s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 5h38m10s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 5h38m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 5h38m11s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 5h38m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 5h37m58s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 5h38m4s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 5h38m6s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 5h37m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 5h38m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 5h38m11s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 6h14m2s
The /subscription/plans endpoint was incorrectly mounted on points_router (which
has prefix=/points), making it accessible at /points/subscription/plans instead
of /subscription/plans. Moved it to the subscription_router (prefix=/subscription)
where it belongs, next to /current /change-plan /cancel /toggle-auto-renew etc.
Also cleaned up:
- Removed misplaced endpoint and unused imports (MembershipPlanItem/MembershipPlansResponse,
MEMBERSHIP_PRICES) from points.py
- Fixed unit tests to load the real subscription module (avoiding sys.modules
shadowing by the integration test fixture)
- Added proper typing import to subscription.py
Endpoint: GET /subscription/plans → returns {plans: [...]} with monthly/quarterly/yearly
tiers, price_cents, monthly_price_cents, duration_days, points_discount, features.
This commit is contained in:
@@ -15,8 +15,6 @@ 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,
|
||||
@@ -302,36 +300,6 @@ def create_recharge_order(
|
||||
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),
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -99,6 +100,39 @@ async def get_current_subscription(
|
||||
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 == "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({
|
||||
"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),
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -143,28 +145,48 @@ class TestPointsRulesDescription:
|
||||
|
||||
|
||||
class TestSubscriptionPlans:
|
||||
def test_plans_endpoint_returns_three_tiers(self):
|
||||
from app.api.routes.points import list_membership_plans
|
||||
@staticmethod
|
||||
def _import_plans_fn():
|
||||
"""Import from the real file to avoid sys.modules shadowing by integration fixtures."""
|
||||
import importlib.util
|
||||
_route_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "..", "apps", "api", "app", "api", "routes", "subscription.py",
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"_real_subscription_routes", os.path.abspath(_route_path)
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
# inject settings before exec
|
||||
import os as _os
|
||||
_os.environ.setdefault("JWT_SECRET_KEY", "test-secret")
|
||||
_spec.loader.exec_module(_mod)
|
||||
return _mod.list_membership_plans
|
||||
|
||||
resp = list_membership_plans(_current_user=_make_cu())
|
||||
plan_ids = {p.plan_id for p in resp.plans}
|
||||
def test_plans_endpoint_returns_three_tiers(self):
|
||||
import os # noqa: F401 (used by _import_plans_fn)
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
plan_ids = {p["plan_id"] for p in 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
|
||||
for p in 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
|
||||
import os # noqa: F401
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
monthly = next(p for p in plans if p["plan_id"] == "monthly")
|
||||
quarterly = next(p for p in plans if p["plan_id"] == "quarterly")
|
||||
yearly = next(p for p in 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 ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user