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

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:
xiaoxia-agent
2026-09-16 09:44:14 +08:00
parent 799a7f7367
commit 9df214b170
3 changed files with 74 additions and 50 deletions
-32
View File
@@ -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),
+34
View File
@@ -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),
+40 -18
View File
@@ -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 ──────────────────────────────────────