From a023ba81305de71f1fdedad308e04d2e0a4f7015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?API=E6=96=87=E6=A1=A3=E7=BB=B4=E6=8A=A4Agent?= Date: Sun, 28 Jun 2026 14:13:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=200=20-=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E6=80=A7=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD=EF=BC=88=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=B3=A8=E5=86=8C/=E9=85=8D=E9=A2=9D=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8/Feature=20Flags/=E6=96=B0=E5=A2=9E=E8=A1=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 packages/infrastructure/ 包 - 实现 ModuleRegistry:模块注册中心,支持能力声明、依赖检查、配额规则 - 重构 QuotaRegistry:注册表模式,3 套餐等级(free/basic/premium),11 配额维度 - 实现 FeatureFlags:三级控制(全局/套餐/用户白名单),5 个初始 Flag - Alembic 迁移 010:5 表新增 metadata JSONB + title_libraries 表 + voice_libraries 表 - 更新 models.py:新增 TitleLibraryModel、VoiceLibraryModel --- alembic/versions/010_phase0_extensibility.py | 117 ++++++++ packages/adapters/sqlalchemy_impl/models.py | 43 +++ packages/domain/quota.py | 287 +++++++++++++++++-- packages/infrastructure/__init__.py | 20 ++ packages/infrastructure/feature_flags.py | 188 ++++++++++++ packages/infrastructure/module_registry.py | 226 +++++++++++++++ 6 files changed, 850 insertions(+), 31 deletions(-) create mode 100644 alembic/versions/010_phase0_extensibility.py create mode 100644 packages/infrastructure/__init__.py create mode 100644 packages/infrastructure/feature_flags.py create mode 100644 packages/infrastructure/module_registry.py diff --git a/alembic/versions/010_phase0_extensibility.py b/alembic/versions/010_phase0_extensibility.py new file mode 100644 index 000000000..512e058d4 --- /dev/null +++ b/alembic/versions/010_phase0_extensibility.py @@ -0,0 +1,117 @@ +"""Phase 0 - 扩展性基础设施:metadata JSONB + title_libraries + voice_libraries + +Revision ID: 010 +Revises: 009 +Create Date: 2026-06-28 + +This migration: +1. Adds metadata JSONB column to 5 tables: + - projects, asset_libraries, assets, edit_templates, generation_tasks +2. Creates title_libraries table (独立标题库,支持跨项目复用) +3. Creates voice_libraries table (配音库,支持 AI 配音管理) +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = "010" +down_revision = "009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # ── 1. Add metadata JSONB to existing tables ── + + conn.execute(sa.text( + "ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" + )) + conn.execute(sa.text( + "ALTER TABLE asset_libraries ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" + )) + conn.execute(sa.text( + "ALTER TABLE assets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" + )) + conn.execute(sa.text( + "ALTER TABLE edit_templates ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" + )) + conn.execute(sa.text( + "ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'" + )) + + # ── 2. Create title_libraries table ── + + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS title_libraries ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + category VARCHAR(50) NOT NULL DEFAULT 'default', + text VARCHAR(500) NOT NULL, + tags JSONB NOT NULL DEFAULT '[]', + usage_count INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_title_libraries_user_id ON title_libraries(user_id)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_title_libraries_category ON title_libraries(category)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_title_libraries_is_active ON title_libraries(is_active)" + )) + + # ── 3. Create voice_libraries table ── + + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS voice_libraries ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + project_id VARCHAR(36), + name VARCHAR(255) NOT NULL, + text TEXT NOT NULL DEFAULT '', + voice_provider VARCHAR(50) NOT NULL DEFAULT '', + voice_id VARCHAR(100) NOT NULL DEFAULT '', + voice_name VARCHAR(100) NOT NULL DEFAULT '', + audio_url VARCHAR(1000) NOT NULL DEFAULT '', + duration FLOAT NOT NULL DEFAULT 0, + file_size INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'completed', + tags JSONB NOT NULL DEFAULT '[]', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_voice_libraries_user_id ON voice_libraries(user_id)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_voice_libraries_project_id ON voice_libraries(project_id)" + )) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_voice_libraries_status ON voice_libraries(status)" + )) + + +def downgrade() -> None: + conn = op.get_bind() + + # Drop new tables + conn.execute(sa.text("DROP TABLE IF EXISTS voice_libraries")) + conn.execute(sa.text("DROP TABLE IF EXISTS title_libraries")) + + # Remove metadata columns + conn.execute(sa.text("ALTER TABLE generation_tasks DROP COLUMN IF EXISTS metadata")) + conn.execute(sa.text("ALTER TABLE edit_templates DROP COLUMN IF EXISTS metadata")) + conn.execute(sa.text("ALTER TABLE assets DROP COLUMN IF EXISTS metadata")) + conn.execute(sa.text("ALTER TABLE asset_libraries DROP COLUMN IF EXISTS metadata")) + conn.execute(sa.text("ALTER TABLE projects DROP COLUMN IF EXISTS metadata")) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 728fef3e8..d4aef271c 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -39,6 +39,7 @@ class ProjectModel(Base): name = Column(String(100), nullable=False) description = Column(Text, nullable=False, default="") shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表 + metadata = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -51,6 +52,7 @@ class AssetLibraryModel(Base): kind = Column(String(20), nullable=False, index=True) asset_count = Column(Float, nullable=False, default=0) total_size = Column(Float, nullable=False, default=0) + metadata = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -80,6 +82,7 @@ class AssetModel(Base): classification_result = Column(Text, nullable=True) quality_score = Column(Float, nullable=True) uploaded_by_user_id = Column(String(36), nullable=False) + metadata = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -110,6 +113,7 @@ class EditTemplateModel(Base): clip_count = Column(Integer, nullable=False, default=3) is_active = Column(Boolean, nullable=False, default=True) created_by_user_id = Column(String(32), nullable=False, default="") + metadata = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -188,6 +192,7 @@ class GenerationTaskModel(Base): started_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True) created_by_user_id = Column(String(32), nullable=False, default="") + metadata = Column(JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -266,3 +271,41 @@ class TaskIssueModel(Base): created_by_user_id = Column(String(32), nullable=False, default="") created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + +class TitleLibraryModel(Base): + __tablename__ = "title_libraries" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=False, index=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=False, default="") + category = Column(String(50), nullable=False, default="default", index=True) + text = Column(String(500), nullable=False) + tags = Column(JSON, nullable=False, default=list) + usage_count = Column(Integer, nullable=False, default=0) + is_active = Column(Boolean, nullable=False, default=True, index=True) + metadata = Column(JSON, nullable=False, default=dict) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class VoiceLibraryModel(Base): + __tablename__ = "voice_libraries" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=False, index=True) + project_id = Column(String(36), nullable=True, index=True) + name = Column(String(255), nullable=False) + text = Column(Text, nullable=False, default="") + voice_provider = Column(String(50), nullable=False, default="") + voice_id = Column(String(100), nullable=False, default="") + voice_name = Column(String(100), nullable=False, default="") + audio_url = Column(String(1000), nullable=False, default="") + duration = Column(Float, nullable=False, default=0) + file_size = Column(Integer, nullable=False, default=0) + status = Column(String(20), nullable=False, default="completed", index=True) + tags = Column(JSON, nullable=False, default=list) + metadata = Column(JSON, nullable=False, default=dict) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + diff --git a/packages/domain/quota.py b/packages/domain/quota.py index ccd87a157..8397f469b 100644 --- a/packages/domain/quota.py +++ b/packages/domain/quota.py @@ -1,44 +1,269 @@ -""" -Quota checker - stub implementation. -Workspace concept removed. All quota checks pass by default. +"""Quota system with registry pattern. + +Three subscription tiers with different limits: +- free: 2GB storage, 5 videos/month, 3 concurrent, 3 templates, 50 titles, 10 voiceovers, no AI voice +- basic: 20GB storage, 30 videos/month, 10 concurrent, 15 templates, 500 titles, 100 voiceovers, AI voice +- premium: 100GB storage, 100 videos/month, 20 concurrent, unlimited templates, 500 titles, 100 voiceovers, AI voice + +Quota dimensions are registered by modules via the ModuleRegistry, +and checked against the user's subscription plan. """ +from __future__ import annotations -class QuotaChecker: - """Stub quota checker - all checks pass since workspace is removed.""" +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, List, Optional - def __init__(self, project_repository=None): - self.project_repository = project_repository - def check_can_create_project(self, user_id=None): - return True, None +class QuotaDimension(str, Enum): + """配额维度 - 所有可量化的资源限制""" + STORAGE_GB = "storage_gb" # 存储空间 (GB) + VIDEOS_PER_MONTH = "videos_per_month" # 每月生成视频数 + MAX_CONCURRENT = "max_concurrent" # 最大并发任务数 + MAX_TEMPLATES = "max_templates" # 最大模板数 + MAX_TITLES = "max_titles" # 最大标题库条目数 + MAX_VOICEOVERS = "max_voiceovers" # 最大配音库条目数 + AI_VOICE_ENABLED = "ai_voice_enabled" # AI 配音是否可用 (0/1) + # 以下维度由扩展模块注册,初始配额为 0(由模块注册时填充) + AI_VOICE_CREDITS = "ai_voice_credits" # AI 配音积分(每月) + BATCH_EXPORT_ENABLED = "batch_export_enabled" # 批量导出 + MULTI_PLATFORM_ENABLED = "multi_platform_enabled" # 多平台发布 + DEDUP_REPORT_ENABLED = "dedup_report_enabled" # 去重检测报告 - def check_storage_available(self, user_id=None, additional_gb=0): - return True, None - def get_quota_status(self, user_id=None): - return { - "subscription_plan": "unlimited", - "projects": {"used": 0, "limit": 999999, "unlimited": True, "usage_percent": 0}, - "storage": {"used_gb": 0, "limit_gb": 999999, "remaining_gb": 999999, "usage_percent": 0}, - } +@dataclass +class QuotaTier: + """一个套餐等级的配额定义""" + name: str + limits: Dict[str, float] = field(default_factory=dict) - def update_storage_usage(self, user_id=None, delta_gb=0): - return True, None + def get_limit(self, dimension: str) -> float: + """获取指定维度的配额限制,未定义返回 0""" + return self.limits.get(dimension, 0) + + def is_unlimited(self, dimension: str) -> bool: + """指定维度是否不限量""" + return self.limits.get(dimension, float("inf")) == float("inf") + + +# 三个套餐等级的配额定义 +QUOTA_TIERS: Dict[str, QuotaTier] = { + "free": QuotaTier( + name="free", + limits={ + QuotaDimension.STORAGE_GB: 2, + QuotaDimension.VIDEOS_PER_MONTH: 5, + QuotaDimension.MAX_CONCURRENT: 3, + QuotaDimension.MAX_TEMPLATES: 3, + QuotaDimension.MAX_TITLES: 50, + QuotaDimension.MAX_VOICEOVERS: 10, + QuotaDimension.AI_VOICE_ENABLED: 0, + QuotaDimension.AI_VOICE_CREDITS: 0, + QuotaDimension.BATCH_EXPORT_ENABLED: 0, + QuotaDimension.MULTI_PLATFORM_ENABLED: 0, + QuotaDimension.DEDUP_REPORT_ENABLED: 0, + }, + ), + "basic": QuotaTier( + name="basic", + limits={ + QuotaDimension.STORAGE_GB: 20, + QuotaDimension.VIDEOS_PER_MONTH: 30, + QuotaDimension.MAX_CONCURRENT: 10, + QuotaDimension.MAX_TEMPLATES: 15, + QuotaDimension.MAX_TITLES: 500, + QuotaDimension.MAX_VOICEOVERS: 100, + QuotaDimension.AI_VOICE_ENABLED: 1, + QuotaDimension.AI_VOICE_CREDITS: 100, + QuotaDimension.BATCH_EXPORT_ENABLED: 1, + QuotaDimension.MULTI_PLATFORM_ENABLED: 0, + QuotaDimension.DEDUP_REPORT_ENABLED: 0, + }, + ), + "premium": QuotaTier( + name="premium", + limits={ + QuotaDimension.STORAGE_GB: 100, + QuotaDimension.VIDEOS_PER_MONTH: 100, + QuotaDimension.MAX_CONCURRENT: 20, + QuotaDimension.MAX_TEMPLATES: float("inf"), # 不限量 + QuotaDimension.MAX_TITLES: 500, + QuotaDimension.MAX_VOICEOVERS: 100, + QuotaDimension.AI_VOICE_ENABLED: 1, + QuotaDimension.AI_VOICE_CREDITS: 500, + QuotaDimension.BATCH_EXPORT_ENABLED: 1, + QuotaDimension.MULTI_PLATFORM_ENABLED: 1, + QuotaDimension.DEDUP_REPORT_ENABLED: 1, + }, + ), +} class QuotaWarningLevel: - NORMAL = "normal" - WARNING = "warning" - CRITICAL = "critical" - EXCEEDED = "exceeded" + """配额告警级别""" + NORMAL = "normal" # 使用量 < 80% + WARNING = "warning" # 80% <= 使用量 < 100% + CRITICAL = "critical" # 95% <= 使用量 < 100% + EXCEEDED = "exceeded" # 使用量 >= 100% -def get_warning_level(usage_percent): - if usage_percent >= 100: - return QuotaWarningLevel.EXCEEDED - elif usage_percent >= 90: - return QuotaWarningLevel.CRITICAL - elif usage_percent >= 80: - return QuotaWarningLevel.WARNING - return QuotaWarningLevel.NORMAL +@dataclass +class QuotaCheckResult: + """配额检查结果""" + allowed: bool + dimension: str + limit: float + used: float + remaining: float + warning_level: str + + @property + def usage_percent(self) -> float: + if self.limit <= 0: + return 100.0 if self.used > 0 else 0.0 + if self.limit == float("inf"): + return 0.0 + return min(100.0, (self.used / self.limit) * 100) + + +class QuotaRegistry: + """配额注册表 + + 管理所有配额维度的定义和套餐限制。 + 扩展模块可通过 register_dimension() 注册新的配额维度。 + """ + + def __init__(self) -> None: + self._dimensions: Dict[str, str] = {} # dimension_name -> description + self._tiers: Dict[str, QuotaTier] = dict(QUOTA_TIERS) + + # 初始化内置维度 + for dim in QuotaDimension: + self._dimensions[dim.value] = dim.name + + def register_dimension( + self, + dimension: str, + description: str, + default_limits: Optional[Dict[str, float]] = None, + ) -> None: + """注册新的配额维度 + + Args: + dimension: 维度名称 + description: 人类可读描述 + default_limits: 各套餐的默认限制 {plan_name: limit} + """ + if dimension in self._dimensions: + return # 幂等 + + self._dimensions[dimension] = description + + # 为各套餐设置默认限制 + if default_limits: + for plan_name, limit in default_limits.items(): + if plan_name in self._tiers: + self._tiers[plan_name].limits[dimension] = limit + else: + # 默认所有套餐该维度为 0 + for tier in self._tiers.values(): + tier.limits.setdefault(dimension, 0) + + def get_tier(self, plan_name: str) -> Optional[QuotaTier]: + """获取套餐配额定义""" + return self._tiers.get(plan_name) + + def get_limit(self, plan_name: str, dimension: str) -> float: + """获取指定套餐在指定维度的限制""" + tier = self._tiers.get(plan_name) + if tier is None: + return 0 + return tier.get_limit(dimension) + + def list_dimensions(self) -> Dict[str, str]: + """列出所有已注册的配额维度""" + return dict(self._dimensions) + + def list_tiers(self) -> List[str]: + """列出所有套餐等级""" + return list(self._tiers.keys()) + + +class QuotaChecker: + """配额检查器 + + 检查用户在指定维度的使用量是否超出限制。 + 需要调用方提供当前使用量(used),本类不直接访问数据库。 + """ + + def __init__(self, registry: Optional[QuotaRegistry] = None) -> None: + self._registry = registry or QuotaRegistry() + + def check( + self, + plan_name: str, + dimension: str, + used: float, + ) -> QuotaCheckResult: + """检查指定维度的配额使用情况 + + Args: + plan_name: 用户套餐等级 (free/basic/premium) + dimension: 配额维度 + used: 当前已使用量 + + Returns: + QuotaCheckResult + """ + limit = self._registry.get_limit(plan_name, dimension) + remaining = max(0, limit - used) if limit != float("inf") else float("inf") + allowed = used < limit if limit != float("inf") else True + warning_level = self._compute_warning_level(used, limit) + + return QuotaCheckResult( + allowed=allowed, + dimension=dimension, + limit=limit, + used=used, + remaining=remaining, + warning_level=warning_level, + ) + + def check_multiple( + self, + plan_name: str, + usage: Dict[str, float], + ) -> List[QuotaCheckResult]: + """批量检查多个维度的配额""" + return [ + self.check(plan_name, dim, used) + for dim, used in usage.items() + ] + + @staticmethod + def _compute_warning_level(used: float, limit: float) -> str: + """计算告警级别""" + if limit <= 0: + return QuotaWarningLevel.EXCEEDED if used > 0 else QuotaWarningLevel.NORMAL + if limit == float("inf"): + return QuotaWarningLevel.NORMAL + + ratio = used / limit + if ratio >= 1.0: + return QuotaWarningLevel.EXCEEDED + if ratio >= 0.95: + return QuotaWarningLevel.CRITICAL + if ratio >= 0.80: + return QuotaWarningLevel.WARNING + return QuotaWarningLevel.NORMAL + + +def get_warning_level(used: float, limit: float) -> str: + """便捷函数:计算配额告警级别""" + return QuotaChecker._compute_warning_level(used, limit) + + +# 全局单例 +quota_registry = QuotaRegistry() +quota_checker = QuotaChecker(quota_registry) diff --git a/packages/infrastructure/__init__.py b/packages/infrastructure/__init__.py new file mode 100644 index 000000000..8d968c987 --- /dev/null +++ b/packages/infrastructure/__init__.py @@ -0,0 +1,20 @@ +"""Infrastructure package for cross-cutting system services. + +This package contains foundational services that support the domain layer: +- ModuleRegistry: 模块注册中心,支持功能模块的动态注册与发现 +- FeatureFlags: Feature Flag 系统,支持按套餐/用户粒度控制功能开关 +""" + +from .module_registry import Module, ModuleRegistry, ModuleStatus, module_registry +from .feature_flags import FeatureFlags, FeatureFlag, FeatureScope, feature_flags + +__all__ = [ + "Module", + "ModuleRegistry", + "ModuleStatus", + "module_registry", + "FeatureFlags", + "FeatureFlag", + "FeatureScope", + "feature_flags", +] diff --git a/packages/infrastructure/feature_flags.py b/packages/infrastructure/feature_flags.py new file mode 100644 index 000000000..cefe46eb8 --- /dev/null +++ b/packages/infrastructure/feature_flags.py @@ -0,0 +1,188 @@ +"""Feature Flags - 功能开关系统 + +支持三级控制: +1. 全局开关:全局启用/禁用某个功能 +2. 套餐级别:按订阅套餐(free/basic/premium)控制 +3. 用户白名单:针对特定用户单独启用 + +优先级:用户白名单 > 套餐级别 > 全局开关 + +Usage: + from packages.infrastructure.feature_flags import feature_flags + + # 检查功能是否启用 + if feature_flags.is_enabled("ai_voice_generation", user_plan="basic", user_id="u123"): + ... + + # 管理员动态更新 + feature_flags.set_global("batch_export", enabled=False) + feature_flags.set_plan_override("multi_platform_output", "premium", True) + feature_flags.set_user_override("deduplication_report", "user_42", True) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Set + +logger = logging.getLogger(__name__) + + +@dataclass +class FeatureFlag: + """单个 Feature Flag 的定义""" + name: str + description: str = "" + # 全局开关,默认 True(启用) + global_enabled: bool = True + # 套餐级别覆盖:{plan_name: bool} + plan_overrides: Dict[str, bool] = field(default_factory=dict) + # 用户白名单:user_id -> bool(True=强制启用,False=强制禁用) + user_overrides: Dict[str, bool] = field(default_factory=dict) + + def is_enabled( + self, + user_plan: Optional[str] = None, + user_id: Optional[str] = None, + ) -> bool: + """判断功能是否对指定用户启用 + + 优先级:用户白名单 > 套餐级别 > 全局开关 + """ + # 1. 用户白名单优先 + if user_id and user_id in self.user_overrides: + return self.user_overrides[user_id] + + # 2. 套餐级别 + if user_plan and user_plan in self.plan_overrides: + return self.plan_overrides[user_plan] + + # 3. 全局开关 + return self.global_enabled + + +class FeatureScope: + """Feature Flag 名称常量,避免硬编码字符串""" + AI_VOICE_GENERATION = "ai_voice_generation" + DEDUPLICATION_REPORT = "deduplication_report" + BATCH_EXPORT = "batch_export" + MULTI_PLATFORM_OUTPUT = "multi_platform_output" + RECIPE_REUSE = "recipe_reuse" + + +class FeatureFlags: + """Feature Flags 管理器 + + 单例模式,全局唯一实例(feature_flags)。 + """ + + def __init__(self) -> None: + self._flags: Dict[str, FeatureFlag] = {} + self._init_defaults() + + def _init_defaults(self) -> None: + """初始化默认 Feature Flags""" + defaults = [ + FeatureFlag( + name=FeatureScope.AI_VOICE_GENERATION, + description="AI 配音生成功能", + global_enabled=True, + plan_overrides={"free": False}, # free 套餐不可用 + ), + FeatureFlag( + name=FeatureScope.DEDUPLICATION_REPORT, + description="素材去重检测报告", + global_enabled=True, + plan_overrides={"free": False, "basic": False}, # 仅 premium + ), + FeatureFlag( + name=FeatureScope.BATCH_EXPORT, + description="批量导出功能", + global_enabled=True, + plan_overrides={"free": False}, + ), + FeatureFlag( + name=FeatureScope.MULTI_PLATFORM_OUTPUT, + description="多平台发布输出", + global_enabled=True, + plan_overrides={"free": False, "basic": False}, # 仅 premium + ), + FeatureFlag( + name=FeatureScope.RECIPE_REUSE, + description="配方复用功能", + global_enabled=True, + ), + ] + for flag in defaults: + self._flags[flag.name] = flag + + def register(self, flag: FeatureFlag) -> None: + """注册一个新的 Feature Flag""" + self._flags[flag.name] = flag + logger.info(f"Feature flag '{flag.name}' registered") + + def get(self, name: str) -> Optional[FeatureFlag]: + """获取 Feature Flag 定义""" + return self._flags.get(name) + + def is_enabled( + self, + name: str, + user_plan: Optional[str] = None, + user_id: Optional[str] = None, + ) -> bool: + """检查功能是否启用 + + Args: + name: Feature flag 名称 + user_plan: 用户订阅套餐 (free/basic/premium) + user_id: 用户 ID + + Returns: + True 如果功能启用,False 否则 + """ + flag = self._flags.get(name) + if flag is None: + logger.warning(f"Feature flag '{name}' not found, defaulting to disabled") + return False + return flag.is_enabled(user_plan=user_plan, user_id=user_id) + + def set_global(self, name: str, enabled: bool) -> None: + """设置全局开关""" + flag = self._flags.get(name) + if flag is None: + raise KeyError(f"Feature flag '{name}' not found") + flag.global_enabled = enabled + logger.info(f"Feature flag '{name}' global set to {enabled}") + + def set_plan_override(self, name: str, plan: str, enabled: bool) -> None: + """设置套餐级别覆盖""" + flag = self._flags.get(name) + if flag is None: + raise KeyError(f"Feature flag '{name}' not found") + flag.plan_overrides[plan] = enabled + logger.info(f"Feature flag '{name}' plan '{plan}' set to {enabled}") + + def set_user_override(self, name: str, user_id: str, enabled: bool) -> None: + """设置用户白名单覆盖""" + flag = self._flags.get(name) + if flag is None: + raise KeyError(f"Feature flag '{name}' not found") + flag.user_overrides[user_id] = enabled + logger.info(f"Feature flag '{name}' user '{user_id}' set to {enabled}") + + def list_flags(self) -> Dict[str, FeatureFlag]: + """列出所有 Feature Flags""" + return dict(self._flags) + + def get_enabled_for_plan(self, plan: str) -> list[str]: + """获取指定套餐下所有启用的功能名称""" + return [ + name for name, flag in self._flags.items() + if flag.is_enabled(user_plan=plan) + ] + + +# 全局单例 +feature_flags = FeatureFlags() diff --git a/packages/infrastructure/module_registry.py b/packages/infrastructure/module_registry.py new file mode 100644 index 000000000..5ce1843f5 --- /dev/null +++ b/packages/infrastructure/module_registry.py @@ -0,0 +1,226 @@ +"""Module Registry - 模块注册中心 + +提供功能模块的动态注册、发现和管理能力。所有扩展模块(如 AI 配音、 +多平台发布、去重检测等)通过此注册中心声明自身能力、依赖和配额消耗规则。 + +设计原则: +- 模块自描述:每个模块声明自己的能力、依赖、配额规则 +- 松耦合:模块间通过注册中心间接通信,不直接依赖 +- 可扩展:新模块只需注册即可被系统识别,无需修改核心代码 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Set + +logger = logging.getLogger(__name__) + + +class ModuleStatus(str, Enum): + """模块运行状态""" + REGISTERED = "registered" # 已注册,未激活 + ACTIVE = "active" # 已激活,可用 + DISABLED = "disabled" # 已禁用(管理员/Feature Flag 控制) + ERROR = "error" # 注册或初始化出错 + + +@dataclass +class QuotaRule: + """模块定义的配额消耗规则 + + 描述该模块消耗哪些配额维度,以及每个操作消耗多少。 + 例如:AI 配音模块每生成一条配音消耗 1 个 ai_voice_credit。 + """ + dimension: str # 配额维度名,如 "ai_voice_credits", "storage_gb" + per_operation: float # 每次操作消耗量 + description: str = "" # 人类可读描述 + + +@dataclass +class ModuleCapability: + """模块声明的一个能力 + + 能力是模块对外暴露的可调用功能单元。 + """ + name: str # 能力名,如 "generate_voice" + description: str = "" # 人类可读描述 + quota_rules: List[QuotaRule] = field(default_factory=list) # 该能力消耗的配额规则 + metadata: Dict[str, Any] = field(default_factory=dict) # 扩展元数据 + + +@dataclass +class Module: + """模块注册信息 + + 每个扩展模块通过 Module 描述自身,注册到 ModuleRegistry。 + """ + name: str # 模块唯一标识,如 "ai_voice" + version: str = "1.0.0" # 模块版本 + description: str = "" # 人类可读描述 + capabilities: List[ModuleCapability] = field(default_factory=list) + dependencies: List[str] = field(default_factory=list) # 依赖的其他模块名 + status: ModuleStatus = ModuleStatus.REGISTERED + config: Dict[str, Any] = field(default_factory=dict) # 模块配置 + _init_func: Optional[Callable] = field(default=None, repr=False) # 初始化回调 + + def activate(self) -> None: + """激活模块""" + if self.status == ModuleStatus.ERROR: + logger.error(f"Cannot activate module '{self.name}': in error state") + return + self.status = ModuleStatus.ACTIVE + logger.info(f"Module '{self.name}' v{self.version} activated") + + def disable(self) -> None: + """禁用模块""" + self.status = ModuleStatus.DISABLED + logger.info(f"Module '{self.name}' disabled") + + +class ModuleRegistry: + """模块注册中心 + + 单例模式,全局唯一实例(module_registry)。 + 提供模块注册、发现、依赖检查、能力查询等功能。 + + Usage: + from packages.infrastructure.module_registry import module_registry + + # 注册模块 + module_registry.register(Module( + name="ai_voice", + version="1.0.0", + description="AI 配音生成模块", + capabilities=[ + ModuleCapability( + name="generate_voice", + description="文本转配音", + quota_rules=[QuotaRule("ai_voice_credits", 1.0, "每次配音消耗 1 积分")], + ), + ], + )) + + # 查询 + mod = module_registry.get("ai_voice") + has_cap = module_registry.has_capability("generate_voice") + """ + + def __init__(self) -> None: + self._modules: Dict[str, Module] = {} + + def register(self, module: Module) -> None: + """注册一个模块 + + Args: + module: 要注册的模块 + + Raises: + ValueError: 模块名已存在 + """ + if module.name in self._modules: + raise ValueError(f"Module '{module.name}' already registered") + + # 检查依赖是否已注册 + for dep in module.dependencies: + if dep not in self._modules: + logger.warning( + f"Module '{module.name}' depends on '{dep}' which is not yet registered. " + f"Call check_dependencies() after all modules are registered." + ) + + self._modules[module.name] = module + logger.info(f"Module '{module.name}' v{module.version} registered") + + # 自动尝试激活 + if self.check_dependencies(module.name): + module.activate() + + def unregister(self, name: str) -> None: + """注销一个模块""" + if name not in self._modules: + raise KeyError(f"Module '{name}' not found") + + # 检查是否有其他模块依赖它 + dependents = [ + m.name for m in self._modules.values() + if name in m.dependencies and m.name != name + ] + if dependents: + raise ValueError( + f"Cannot unregister module '{name}': depended on by {dependents}" + ) + + del self._modules[name] + logger.info(f"Module '{name}' unregistered") + + def get(self, name: str) -> Optional[Module]: + """按名称获取模块,不存在返回 None""" + return self._modules.get(name) + + def list_modules(self, status: Optional[ModuleStatus] = None) -> List[Module]: + """列出所有模块,可按状态过滤""" + modules = list(self._modules.values()) + if status is not None: + modules = [m for m in modules if m.status == status] + return modules + + def has_capability(self, capability_name: str) -> bool: + """检查是否有任何已激活模块提供指定能力""" + for module in self._modules.values(): + if module.status != ModuleStatus.ACTIVE: + continue + for cap in module.capabilities: + if cap.name == capability_name: + return True + return False + + def get_capability(self, capability_name: str) -> Optional[ModuleCapability]: + """获取指定能力的定义,返回第一个匹配的""" + for module in self._modules.values(): + if module.status != ModuleStatus.ACTIVE: + continue + for cap in module.capabilities: + if cap.name == capability_name: + return cap + return None + + def get_quota_rules(self, capability_name: str) -> List[QuotaRule]: + """获取指定能力的配额消耗规则""" + cap = self.get_capability(capability_name) + if cap is None: + return [] + return cap.quota_rules + + def check_dependencies(self, module_name: str) -> bool: + """检查指定模块的所有依赖是否都已注册且激活""" + module = self._modules.get(module_name) + if module is None: + return False + for dep in module.dependencies: + dep_module = self._modules.get(dep) + if dep_module is None or dep_module.status != ModuleStatus.ACTIVE: + return False + return True + + def get_active_capabilities(self) -> Dict[str, List[str]]: + """获取所有已激活模块的能力列表 + + Returns: + {module_name: [capability_name, ...]} + """ + result = {} + for module in self._modules.values(): + if module.status == ModuleStatus.ACTIVE and module.capabilities: + result[module.name] = [cap.name for cap in module.capabilities] + return result + + def clear(self) -> None: + """清空所有注册(仅用于测试)""" + self._modules.clear() + + +# 全局单例 +module_registry = ModuleRegistry() -- 2.54.0