feat: Phase 0 - 扩展性基础设施(模块注册/配额注册表/Feature Flags/新增表) #72

Merged
xiaoxia merged 1 commits from feat/phase0-infrastructure into develop 2026-06-28 14:17:42 +08:00
6 changed files with 850 additions and 31 deletions
@@ -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"))
@@ -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))
+256 -31
View File
@@ -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)
+20
View File
@@ -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",
]
+188
View File
@@ -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 -> boolTrue=强制启用,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()
+226
View File
@@ -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()