From a4b61c7dbb7d7e48cc98baac9bd916fbdf412f0b Mon Sep 17 00:00:00 2001 From: Xiaoxia Agent Date: Sun, 4 Oct 2026 00:19:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(catalog):=20=E4=BC=9A=E5=91=98=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E4=B8=8E=E7=A7=AF=E5=88=86=E5=8C=85=E6=94=B9=E8=AF=BB?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E7=9C=9F=E5=AE=9E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 application/catalog 服务,从共享库 plans / credit_packages 读取 - /subscription/plans 不再返回硬编码月卡/季卡/年卡,仅返回 is_enabled=true 套餐 - /points/packages 改读 credit_packages(is_active=true) - 价格统一元转分;quotas 映射为中文 features - 30 秒 TTL 缓存,后台启停/改价后用户端最多 30 秒生效 --- apps/api/app/api/routes/points.py | 27 +-- apps/api/app/api/routes/subscription.py | 28 +-- packages/application/catalog/__init__.py | 1 + packages/application/catalog/admin_catalog.py | 160 ++++++++++++++++++ 4 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 packages/application/catalog/__init__.py create mode 100644 packages/application/catalog/admin_catalog.py diff --git a/apps/api/app/api/routes/points.py b/apps/api/app/api/routes/points.py index 531a68cd6..17a0a8e70 100644 --- a/apps/api/app/api/routes/points.py +++ b/apps/api/app/api/routes/points.py @@ -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) diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py index 3067ac2fc..0e1bb76a3 100755 --- a/apps/api/app/api/routes/subscription.py +++ b/apps/api/app/api/routes/subscription.py @@ -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]) diff --git a/packages/application/catalog/__init__.py b/packages/application/catalog/__init__.py new file mode 100644 index 000000000..2224531b0 --- /dev/null +++ b/packages/application/catalog/__init__.py @@ -0,0 +1 @@ +"""应用层:对外展示目录(套餐/积分包)。""" diff --git a/packages/application/catalog/admin_catalog.py b/packages/application/catalog/admin_catalog.py new file mode 100644 index 000000000..f37dc3d27 --- /dev/null +++ b/packages/application/catalog/admin_catalog.py @@ -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)