feat: Phase 0 - 扩展性基础设施(模块注册/配额注册表/Feature Flags/新增表) #72
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user