feat(catalog): 会员套餐与积分包改读管理后台真实数据
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m28s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m18s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 7m11s
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 / Validate - Security (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled

- 新增 application/catalog 服务,从共享库 plans / credit_packages 读取
- /subscription/plans 不再返回硬编码月卡/季卡/年卡,仅返回 is_enabled=true 套餐
- /points/packages 改读 credit_packages(is_active=true)
- 价格统一元转分;quotas 映射为中文 features
- 30 秒 TTL 缓存,后台启停/改价后用户端最多 30 秒生效
This commit is contained in:
Xiaoxia Agent
2026-10-04 00:19:58 +08:00
parent 3bb9c5dd4e
commit a4b61c7dbb
4 changed files with 180 additions and 36 deletions
+15 -12
View File
@@ -145,19 +145,22 @@ def get_rules(
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,
)
"""查询可购买的积分包列表(读管理后台 credit_packages 表真实数据)。
仅返回 is_active=true;后台改价/启停后最多 30 秒生效。
"""
from packages.application.catalog.admin_catalog import get_points_packages
packages = [
PointsPackageItem(
code=row["code"],
name=row["name"],
points=row["points"],
price_cents=row["price_cents"],
unit_price=row["unit_price"],
)
for row in get_points_packages()
]
mt = _member_type(current_user)
discount = MEMBER_DISCOUNT.get(mt) if mt else None
return PointsPackagesResponse(packages=packages, user_discount=discount)
+4 -24
View File
@@ -86,33 +86,13 @@ async def get_current_subscription(
def list_membership_plans(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> dict[str, list[dict[str, Any]]]:
"""查询所有会员档位(供前端会员购买页展示)。
"""查询可购买的会员套餐(读管理后台 plans 表真实数据)。
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
仅返回 is_enabled=true 的套餐;后台启停/改价后最多 30 秒生效。
"""
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
from packages.application.catalog.admin_catalog import get_membership_plans
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 == MembershipType.MONTHLY:
features.update({"free_clips_daily": 2})
elif plan_id == MembershipType.QUARTERLY:
features.update({"free_clips_daily": 5})
elif plan_id == MembershipType.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}
return {"plans": get_membership_plans()}
@router.get("/billing-records", response_model=list[BillingRecord])
+1
View File
@@ -0,0 +1 @@
"""应用层:对外展示目录(套餐/积分包)。"""
@@ -0,0 +1,160 @@
"""读取管理后台配置的会员套餐 / 积分充值包(共享库真实数据)。
替代旧的硬编码 MEMBERSHIP_PRICES / POINTS_PACKAGES。
短 TTL 缓存(30 秒),后台改价/启停后用户端最多 30 秒可见。
"""
from __future__ import annotations
import threading
import time
from typing import Any
_CACHE_TTL = 30.0
_lock = threading.Lock()
_cache: dict[str, tuple[float, Any]] = {}
_QUOTA_LABELS = {
"4k": "4K 超清分辨率",
"batch_render": "批量渲染",
"priority_queue": "优先处理队列",
"ai_matting": "AI 智能抠像",
"remove_watermark": "去水印",
}
def _cached(key: str, loader):
now = time.time()
hit = _cache.get(key)
if hit and now - hit[0] < _CACHE_TTL:
return hit[1]
with _lock:
hit = _cache.get(key)
if hit and time.time() - hit[0] < _CACHE_TTL:
return hit[1]
value = loader()
_cache[key] = (time.time(), value)
return value
def _quota_features(quotas: dict[str, Any] | None) -> dict[str, Any]:
quotas = quotas or {}
features: dict[str, Any] = {}
for k, v in quotas.items():
if k == "credits_per_month":
features["credits_per_month"] = v
elif k in _QUOTA_LABELS:
features[_QUOTA_LABELS[k]] = v
else:
features[k] = v
return features
def get_membership_plans() -> list[dict[str, Any]]:
"""读取 is_enabled=true 的套餐,按年/月周期展开为用户端档位。"""
def _load() -> list[dict[str, Any]]:
from sqlalchemy import text
from packages.adapters.sqlalchemy_impl.session import SessionLocal
if SessionLocal is None:
return []
session = SessionLocal()
try:
rows = session.execute(
text(
"""
SELECT plan_key, name, description, monthly_price, yearly_price,
quotas, display_order
FROM plans
WHERE is_enabled = TRUE
ORDER BY display_order NULLS LAST, created_at
"""
)
).fetchall()
finally:
session.close()
plans: list[dict[str, Any]] = []
for r in rows:
base_features = _quota_features(r.quotas if isinstance(r.quotas, dict) else None)
if r.yearly_price and float(r.yearly_price) > 0:
plans.append(
{
"plan_id": r.plan_key,
"billing_cycle": "yearly",
"name": r.name,
"description": r.description,
"price_cents": int(round(float(r.yearly_price) * 100)),
"monthly_price_cents": int(round(float(r.yearly_price) * 100 / 12)),
"duration_days": 365,
"features": dict(base_features),
}
)
if r.monthly_price and float(r.monthly_price) > 0:
plans.append(
{
"plan_id": r.plan_key,
"billing_cycle": "monthly",
"name": r.name,
"description": r.description,
"price_cents": int(round(float(r.monthly_price) * 100)),
"monthly_price_cents": int(round(float(r.monthly_price) * 100)),
"duration_days": 30,
"features": dict(base_features),
}
)
return plans
return _cached("membership_plans", _load)
def get_points_packages() -> list[dict[str, Any]]:
"""读取 is_active=true 的积分充值包。"""
def _load() -> list[dict[str, Any]]:
from sqlalchemy import text
from packages.adapters.sqlalchemy_impl.session import SessionLocal
if SessionLocal is None:
return []
session = SessionLocal()
try:
rows = session.execute(
text(
"""
SELECT package_key, name, price, credits, bonus_credits,
is_recommended, description, sort_order
FROM credit_packages
WHERE is_active = TRUE
ORDER BY sort_order NULLS LAST, price
"""
)
).fetchall()
finally:
session.close()
packages: list[dict[str, Any]] = []
for r in rows:
total_points = int(r.credits or 0) + int(r.bonus_credits or 0)
price_cents = int(round(float(r.price) * 100))
unit = (price_cents / 100 / total_points) if total_points else 0
packages.append(
{
"code": r.package_key,
"name": r.name,
"points": total_points,
"bonus_credits": int(r.bonus_credits or 0),
"price_cents": price_cents,
"unit_price": f"¥{unit:.3f}/积分",
"is_recommended": bool(r.is_recommended),
"description": r.description,
}
)
return packages
return _cached("points_packages", _load)