diff --git a/alembic/env.py b/alembic/env.py old mode 100644 new mode 100755 index b2c4797dd..0999bdf4e --- a/alembic/env.py +++ b/alembic/env.py @@ -1,4 +1,3 @@ -import os from logging.config import fileConfig from sqlalchemy import engine_from_config, pool @@ -8,13 +7,17 @@ from alembic import context # Import your models' Base here from packages.adapters.sqlalchemy_impl.models import Base +# 使用统一配置入口获取 database_url,而非直接读环境变量 +from packages.config import get_shared_settings + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config -database_url = os.getenv("DATABASE_URL") -if database_url: - config.set_main_option("sqlalchemy.url", database_url) +# 从统一配置系统获取 database_url,确保与应用使用同一配置源 +settings = get_shared_settings() +if settings.database_url: + config.set_main_option("sqlalchemy.url", settings.database_url) # Interpret the config file for Python logging. # This line sets up loggers basically. diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 9b52edd37..1749c0ed5 100755 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -1,307 +1,27 @@ -"""API 服务配置 — 继承 SharedSettings,只追加 API 特有字段。 +"""API 服务配置(向后兼容层)。 -通用配置(DB/Redis/OSS/Celery/CosyVoice/Doubao 等)统一在 -packages/shared/config.py 的 SharedSettings 中定义,这里不重复。 +统一配置已迁移到 packages.config.api_settings。 +新代码请使用: + from packages.config import APISettings, get_api_settings -历史上 API 端使用 UPPER_CASE 命名风格的字段,目前通过 property -别名向后兼容。新代码统一使用 snake_case(继承自 SharedSettings)。 +本文件保留 Settings 类名、get_settings() 函数、settings 模块级单例, +确保所有旧的 import 路径仍然有效。 """ -import os -from typing import Optional - -from pydantic import AliasChoices, Field, field_validator -from pydantic_settings import SettingsConfigDict - -from packages.shared.config import SharedSettings - - -class Settings(SharedSettings): - """API 服务专用配置。 - - 通用配置继承自 SharedSettings,这里只定义 API 独有字段。 - """ - - # ── 应用基本信息 ──────────────────────────────────────────────────── - app_name: str = "xiaoxia-saas" - app_version: str = "0.1.61" - - # 应用基础 URL,用于生成认证邮件中的链接 - # 开发环境默认 http://localhost:3000 - # 生产环境应通过环境变量 APP_BASE_URL 设置 - app_base_url: str = "http://localhost:3000" - - # Container bind address; external expose is controlled by Docker/Nginx. - api_host: str = "0.0.0.0" # nosec: B104 - api_port: int = 8000 - - # ── 数据库特有 ────────────────────────────────────────────────────── - use_in_memory_db: bool = False - - # ── Redis 特有 ────────────────────────────────────────────────────── - enable_redis_sessions: bool = False - - # ── JWT ──────────────────────────────────────────────────────────── - # JWT secret key - MUST be set via environment variable, no default allowed - jwt_secret_key: Optional[str] = None - - # JWT 算法与过期时间 - jwt_algorithm: str = "HS256" - jwt_access_token_expire_minutes: int = 30 - jwt_refresh_token_expire_days: int = 30 - - @field_validator("jwt_secret_key", mode="before") - @classmethod - def validate_jwt_secret_key(cls, v): - if v is None or v == "": - raise ValueError( - "JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!" - ) - # Block known insecure default values - insecure_defaults = [ - "your-secret-key-change-in-production", - "your-secret-key", - "secret", - "changeme", - "password", - ] - if v.lower() in [d.lower() for d in insecure_defaults]: - raise ValueError( - f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable." - ) - return v - - # ── 邮件 ──────────────────────────────────────────────────────────── - enable_email_delivery: bool = False - smtp_host: str = "smtp.gmail.com" - smtp_port: int = 587 - smtp_user: str = "" - smtp_password: str = "" - smtp_from_email: str = "" - smtp_from_name: str = "小虾 SaaS" - smtp_use_tls: bool = True - - # ── OSS 特有校验 ──────────────────────────────────────────────────── - @field_validator("oss_access_key_id", mode="before") - @classmethod - def validate_oss_access_key_id(cls, v): - if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development": - raise ValueError( - "OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. " - "Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)." - ) - return v or "" - - @field_validator("oss_access_key_secret", mode="before") - @classmethod - def validate_oss_access_key_secret(cls, v): - if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development": - raise ValueError( - "OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. " - "Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)." - ) - return v or "" - - oss_direct_upload_max_mb: int = Field( - default=2000, - validation_alias=AliasChoices("oss_direct_upload_max_mb", "max_upload_size_mb"), - ) - - # ── CORS ──────────────────────────────────────────────────────────── - cors_origins_raw: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000" - - # ── 渲染引擎 ──────────────────────────────────────────────────────── - # 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService - render_engine: str = "legacy" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore", - ) - - @property - def cors_origins(self) -> list[str]: - return [origin.strip() for origin in self.cors_origins_raw.split(",") if origin.strip()] - - # ── 向后兼容:UPPER_CASE property 别名 ────────────────────────────── - # 新代码请使用 snake_case(继承的字段名),以下别名仅用于兼容旧代码 - - @property - def APP_NAME(self) -> str: - return self.app_name - - @property - def APP_VERSION(self) -> str: - return self.app_version - - @property - def ENVIRONMENT(self) -> str: - return self.environment - - @property - def DEBUG(self) -> bool: - return self.debug - - @property - def APP_BASE_URL(self) -> str: - return self.app_base_url - - @property - def API_HOST(self) -> str: - return self.api_host - - @property - def API_PORT(self) -> int: - return self.api_port - - @property - def DATABASE_URL(self) -> str: - return self.database_url - - @property - def DATABASE_POOL_SIZE(self) -> int: - return self.database_pool_size - - @property - def DATABASE_MAX_OVERFLOW(self) -> int: - return self.database_max_overflow - - @property - def DATABASE_POOL_TIMEOUT(self) -> int: - return self.database_pool_timeout - - @property - def DATABASE_POOL_RECYCLE(self) -> int: - return self.database_pool_recycle - - @property - def USE_IN_MEMORY_DB(self) -> bool: - return self.use_in_memory_db - - @property - def AUTO_CREATE_SCHEMA(self) -> bool: - return self.auto_create_schema - - @property - def REDIS_URL(self) -> str: - return self.redis_url - - @property - def ENABLE_REDIS_SESSIONS(self) -> bool: - return self.enable_redis_sessions - - @property - def JWT_SECRET_KEY(self) -> Optional[str]: - return self.jwt_secret_key - - @property - def JWT_ALGORITHM(self) -> str: - return self.jwt_algorithm - - @property - def JWT_ACCESS_TOKEN_EXPIRE_MINUTES(self) -> int: - return self.jwt_access_token_expire_minutes - - @property - def JWT_REFRESH_TOKEN_EXPIRE_DAYS(self) -> int: - return self.jwt_refresh_token_expire_days - - @property - def ENABLE_EMAIL_DELIVERY(self) -> bool: - return self.enable_email_delivery - - @property - def SMTP_HOST(self) -> str: - return self.smtp_host - - @property - def SMTP_PORT(self) -> int: - return self.smtp_port - - @property - def SMTP_USER(self) -> str: - return self.smtp_user - - @property - def SMTP_PASSWORD(self) -> str: - return self.smtp_password - - @property - def SMTP_FROM_EMAIL(self) -> str: - return self.smtp_from_email - - @property - def SMTP_FROM_NAME(self) -> str: - return self.smtp_from_name - - @property - def SMTP_USE_TLS(self) -> bool: - return self.smtp_use_tls - - @property - def CELERY_BROKER_URL(self) -> str: - return self.celery_broker_url - - @property - def CELERY_RESULT_BACKEND(self) -> str: - return self.celery_result_backend - - @property - def OSS_ENDPOINT(self) -> str: - return self.oss_endpoint - - @property - def OSS_ACCESS_KEY_ID(self) -> str: - return self.oss_access_key_id - - @property - def OSS_ACCESS_KEY_SECRET(self) -> str: - return self.oss_access_key_secret - - @property - def OSS_BUCKET_NAME(self) -> str: - return self.oss_bucket_name - - @property - def OSS_DIRECT_UPLOAD_MAX_MB(self) -> int: - return self.oss_direct_upload_max_mb - - @property - def OSS_DIRECT_UPLOAD_EXPIRE_SECONDS(self) -> int: - return self.oss_direct_upload_expire_seconds - - @property - def CORS_ORIGINS_RAW(self) -> str: - return self.cors_origins_raw - - @property - def CORS_ORIGINS(self) -> list[str]: - return self.cors_origins - - @property - def RENDER_ENGINE(self) -> str: - return self.render_engine - - -_settings: Optional["Settings"] = None - - -def get_settings() -> "Settings": - """获取 API 配置单例。 - - 优先读取 APP_ENV 指定的环境文件(.env.{env}),不存在则读 .env。 - """ - global _settings - if _settings is None: - env = os.getenv("APP_ENV", "development") - env_file = f".env.{env}" if env != "development" else ".env" - if os.path.exists(env_file): - _settings = Settings(_env_file=env_file) - else: - _settings = Settings() - return _settings - +from packages.config import APISettings as Settings +from packages.config import get_api_settings as get_settings +from packages.config import reload_settings_cache +# 模块级单例(向后兼容) settings = get_settings() + +# 暴露旧的 reload_settings 函数名 +def reload_settings(): + """重新加载配置(测试用)。""" + reload_settings_cache() + global settings + settings = get_settings() + return settings + + +__all__ = ["Settings", "get_settings", "settings", "reload_settings"] diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py old mode 100644 new mode 100755 index 53b81f20f..df8553db4 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -1,19 +1,20 @@ -"""Compatibility layer for the canonical API settings module. +"""向后兼容层 — 配置已统一到 packages.config。 -Use `app.config` as the single source of truth for API configuration. -This module remains only for older imports during migration. +新代码请使用: + from packages.config import get_api_settings, APISettings """ -from app.config import Settings as AppSettings -from app.config import get_settings, settings +from packages.config import APISettings as AppSettings +from packages.config import get_api_settings as get_settings +from packages.config import reload_settings_cache def reload_settings() -> AppSettings: - """Reload settings for tests and legacy callers.""" - import app.config as canonical_config + """重新加载配置(测试用)。""" + reload_settings_cache() + return get_settings() - canonical_config.settings = canonical_config.get_settings() - return canonical_config.settings +settings = get_settings() __all__ = ["AppSettings", "get_settings", "reload_settings", "settings"] diff --git a/apps/worker/worker_app/core/config.py b/apps/worker/worker_app/core/config.py index e41a4ba9a..9d9c9e611 100755 --- a/apps/worker/worker_app/core/config.py +++ b/apps/worker/worker_app/core/config.py @@ -1,61 +1,22 @@ -"""Worker 服务配置 — 继承 SharedSettings,只追加 Worker 特有字段。 +"""Worker 服务配置(向后兼容层)。 -通用配置(DB/Redis/Celery/OSS/CosyVoice/Doubao 等)统一在 -packages/shared/config.py 的 SharedSettings 中定义,这里不重复。 +统一配置已迁移到 packages.config.worker_settings。 +新代码请使用: + from packages.config import WorkerSettings, get_worker_settings """ -import os -from typing import Optional - -from pydantic_settings import SettingsConfigDict - -from packages.shared.config import SharedSettings +from packages.config import WorkerSettings, get_worker_settings, reload_settings_cache -class WorkerSettings(SharedSettings): - """Worker 服务专用配置。 - - 通用配置继承自 SharedSettings,这里只定义 Worker 独有字段。 - Celery broker/backend 使用继承的 celery_broker_url / celery_result_backend; - 历史上 Worker 使用 broker_url / result_backend 字段名,通过 property 别名兼容。 - """ - - # ── Worker 特有 ──────────────────────────────────────────────────── - worker_name: str = "xiaoxia-saas-worker" - worker_concurrency: int = 4 - worker_max_tasks_per_child: int = 1000 - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore", - ) - - # ── 向后兼容:Celery 字段名别名 ────────────────────────────────── - @property - def broker_url(self) -> str: - return self.celery_broker_url - - @property - def result_backend(self) -> str: - return self.celery_result_backend +def get_settings() -> WorkerSettings: + """获取 Worker 配置单例(向后兼容)。""" + return get_worker_settings() -_settings: Optional["WorkerSettings"] = None +def reload_settings() -> WorkerSettings: + """重新加载配置(测试用)。""" + reload_settings_cache() + return get_worker_settings() -def get_settings() -> "WorkerSettings": - """获取 Worker 配置单例。 - - 优先读取 APP_ENV 指定的环境文件(.env.{env}),不存在则读 .env。 - """ - global _settings - if _settings is None: - env = os.getenv("APP_ENV", "development") - env_file = f".env.{env}" if env != "development" else ".env" - if os.path.exists(env_file): - _settings = WorkerSettings(_env_file=env_file) - else: - _settings = WorkerSettings() - return _settings +__all__ = ["WorkerSettings", "get_settings", "reload_settings"] diff --git a/packages/config/__init__.py b/packages/config/__init__.py new file mode 100755 index 000000000..22d0715c1 --- /dev/null +++ b/packages/config/__init__.py @@ -0,0 +1,34 @@ +"""统一配置入口 — 整个项目的配置都从这里获取。 + +所有服务共享同一个配置包,消除重复定义和不一致。 + +用法: + from packages.config import get_api_settings, get_worker_settings, get_shared_settings + from packages.config import APISettings, WorkerSettings, SharedSettings + +架构: + packages/config/ + ├── base.py # SharedSettings 基类 + 统一单例管理 + ├── api_settings.py # APISettings(API 特有配置) + └── worker_settings.py # WorkerSettings(Worker 特有配置) +""" + +from packages.config.api_settings import APISettings, get_api_settings +from packages.config.base import ( + SharedSettings, + get_cached_settings, + get_shared_settings, + reload_settings_cache, +) +from packages.config.worker_settings import WorkerSettings, get_worker_settings + +__all__ = [ + "SharedSettings", + "APISettings", + "WorkerSettings", + "get_shared_settings", + "get_api_settings", + "get_worker_settings", + "get_cached_settings", + "reload_settings_cache", +] diff --git a/packages/config/api_settings.py b/packages/config/api_settings.py new file mode 100755 index 000000000..a6192aeee --- /dev/null +++ b/packages/config/api_settings.py @@ -0,0 +1,288 @@ +"""API 服务配置 — 继承 SharedSettings,只追加 API 特有字段。 + +通用配置统一在 packages/config/base.py 的 SharedSettings 中定义,这里不重复。 +历史上 API 端使用 UPPER_CASE 命名风格的字段,目前通过 property 别名向后兼容。 +新代码统一使用 snake_case(继承自 SharedSettings)。 +""" + +import os +from typing import Optional + +from pydantic import AliasChoices, Field, field_validator +from pydantic_settings import SettingsConfigDict + +from packages.config.base import SharedSettings, get_cached_settings + + +class APISettings(SharedSettings): + """API 服务专用配置。 + + 通用配置继承自 SharedSettings,这里只定义 API 独有字段。 + """ + + # ── 应用基本信息 ──────────────────────────────────────────────────── + app_name: str = "xiaoxia-saas" + app_version: str = "0.1.61" + + # 应用基础 URL,用于生成认证邮件中的链接 + app_base_url: str = "http://localhost:3000" + + # Container bind address; external expose is controlled by Docker/Nginx. + api_host: str = "0.0.0.0" # nosec: B104 + api_port: int = 8000 + + # ── 数据库特有 ────────────────────────────────────────────────────── + use_in_memory_db: bool = False + + # ── Redis 特有 ────────────────────────────────────────────────────── + enable_redis_sessions: bool = False + + # ── JWT ──────────────────────────────────────────────────────────── + # JWT secret key - MUST be set via environment variable, no default allowed + jwt_secret_key: Optional[str] = None + + # JWT 算法与过期时间 + jwt_algorithm: str = "HS256" + jwt_access_token_expire_minutes: int = 30 + jwt_refresh_token_expire_days: int = 30 + + @field_validator("jwt_secret_key", mode="before") + @classmethod + def validate_jwt_secret_key(cls, v): + if v is None or v == "": + raise ValueError( + "JWT_SECRET_KEY must be set via environment variable. " + "Do not use default value in production!" + ) + # Block known insecure default values + insecure_defaults = [ + "your-secret-key-change-in-production", + "your-secret-key", + "secret", + "changeme", + "password", + ] + if v.lower() in [d.lower() for d in insecure_defaults]: + raise ValueError( + f"JWT_SECRET_KEY '{v}' is insecure. " + "Please set a strong random secret via environment variable." + ) + return v + + # ── 邮件 ──────────────────────────────────────────────────────────── + enable_email_delivery: bool = False + smtp_host: str = "smtp.gmail.com" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from_email: str = "" + smtp_from_name: str = "小虾 SaaS" + smtp_use_tls: bool = True + + # ── OSS 特有校验 ──────────────────────────────────────────────────── + @field_validator("oss_access_key_id", mode="before") + @classmethod + def validate_oss_access_key_id(cls, v): + if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development": + raise ValueError( + "OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. " + "Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)." + ) + return v or "" + + @field_validator("oss_access_key_secret", mode="before") + @classmethod + def validate_oss_access_key_secret(cls, v): + if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development": + raise ValueError( + "OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. " + "Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)." + ) + return v or "" + + oss_direct_upload_max_mb: int = Field( + default=2000, + validation_alias=AliasChoices("oss_direct_upload_max_mb", "max_upload_size_mb"), + ) + + # ── CORS ──────────────────────────────────────────────────────────── + cors_origins_raw: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000" + + # ── 渲染引擎 ──────────────────────────────────────────────────────── + # 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService + render_engine: str = "legacy" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + @property + def cors_origins(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins_raw.split(",") if origin.strip()] + + # ── 向后兼容:UPPER_CASE property 别名 ────────────────────────────── + # 新代码请使用 snake_case(继承的字段名),以下别名仅用于兼容旧代码 + + @property + def APP_NAME(self) -> str: + return self.app_name + + @property + def APP_VERSION(self) -> str: + return self.app_version + + @property + def ENVIRONMENT(self) -> str: + return self.environment + + @property + def DEBUG(self) -> bool: + return self.debug + + @property + def APP_BASE_URL(self) -> str: + return self.app_base_url + + @property + def API_HOST(self) -> str: + return self.api_host + + @property + def API_PORT(self) -> int: + return self.api_port + + @property + def DATABASE_URL(self) -> str: + return self.database_url + + @property + def DATABASE_POOL_SIZE(self) -> int: + return self.database_pool_size + + @property + def DATABASE_MAX_OVERFLOW(self) -> int: + return self.database_max_overflow + + @property + def DATABASE_POOL_TIMEOUT(self) -> int: + return self.database_pool_timeout + + @property + def DATABASE_POOL_RECYCLE(self) -> int: + return self.database_pool_recycle + + @property + def USE_IN_MEMORY_DB(self) -> bool: + return self.use_in_memory_db + + @property + def AUTO_CREATE_SCHEMA(self) -> bool: + return self.auto_create_schema + + @property + def REDIS_URL(self) -> str: + return self.redis_url + + @property + def ENABLE_REDIS_SESSIONS(self) -> bool: + return self.enable_redis_sessions + + @property + def JWT_SECRET_KEY(self) -> Optional[str]: + return self.jwt_secret_key + + @property + def JWT_ALGORITHM(self) -> str: + return self.jwt_algorithm + + @property + def JWT_ACCESS_TOKEN_EXPIRE_MINUTES(self) -> int: + return self.jwt_access_token_expire_minutes + + @property + def JWT_REFRESH_TOKEN_EXPIRE_DAYS(self) -> int: + return self.jwt_refresh_token_expire_days + + @property + def ENABLE_EMAIL_DELIVERY(self) -> bool: + return self.enable_email_delivery + + @property + def SMTP_HOST(self) -> str: + return self.smtp_host + + @property + def SMTP_PORT(self) -> int: + return self.smtp_port + + @property + def SMTP_USER(self) -> str: + return self.smtp_user + + @property + def SMTP_PASSWORD(self) -> str: + return self.smtp_password + + @property + def SMTP_FROM_EMAIL(self) -> str: + return self.smtp_from_email + + @property + def SMTP_FROM_NAME(self) -> str: + return self.smtp_from_name + + @property + def SMTP_USE_TLS(self) -> bool: + return self.smtp_use_tls + + @property + def CELERY_BROKER_URL(self) -> str: + return self.celery_broker_url + + @property + def CELERY_RESULT_BACKEND(self) -> str: + return self.celery_result_backend + + @property + def OSS_ENDPOINT(self) -> str: + return self.oss_endpoint + + @property + def OSS_ACCESS_KEY_ID(self) -> str: + return self.oss_access_key_id + + @property + def OSS_ACCESS_KEY_SECRET(self) -> str: + return self.oss_access_key_secret + + @property + def OSS_BUCKET_NAME(self) -> str: + return self.oss_bucket_name + + @property + def OSS_DIRECT_UPLOAD_MAX_MB(self) -> int: + return self.oss_direct_upload_max_mb + + @property + def OSS_DIRECT_UPLOAD_EXPIRE_SECONDS(self) -> int: + return self.oss_direct_upload_expire_seconds + + @property + def CORS_ORIGINS_RAW(self) -> str: + return self.cors_origins_raw + + @property + def CORS_ORIGINS(self) -> list[str]: + return self.cors_origins + + @property + def RENDER_ENGINE(self) -> str: + return self.render_engine + + +def get_api_settings() -> APISettings: + """获取 API 配置单例(统一入口)。""" + return get_cached_settings(APISettings) diff --git a/packages/config/base.py b/packages/config/base.py new file mode 100755 index 000000000..905c09667 --- /dev/null +++ b/packages/config/base.py @@ -0,0 +1,110 @@ +"""统一配置基类 — 所有服务共享的基础配置。 + +数据库/Redis/OSS/Celery/AI服务等通用配置统一定义在此。 +API 和 Worker 各自的 Settings 类继承本类,只追加服务特有字段。 +单例模式和 env 文件加载逻辑也统一在这里实现。 +""" + +import os +from typing import Optional, Type, TypeVar + +from pydantic_settings import BaseSettings, SettingsConfigDict + +T = TypeVar("T", bound=BaseSettings) + + +class SharedSettings(BaseSettings): + """所有服务共享的基础配置。 + + API 和 Worker 都继承本类,确保: + 1. 数据库/Redis/OSS/Celery 等核心配置默认值一致 + 2. 环境变量命名统一(snake_case,pydantic-settings 自动兼容大写) + 3. env 文件加载逻辑只实现一次 + """ + + # ── 环境 ────────────────────────────────────────────────────────────── + environment: str = "development" + debug: bool = True + auto_create_schema: bool = False + + # ── 数据库 ──────────────────────────────────────────────────────────── + database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas" + database_pool_size: int = 20 + database_max_overflow: int = 10 # pool_size(20) + max_overflow(10) = 最大30连接 + database_pool_timeout: int = 30 + database_pool_recycle: int = 3600 + + # ── Redis ──────────────────────────────────────────────────────────── + redis_url: str = "redis://localhost:6379/0" + + # ── Celery ─────────────────────────────────────────────────────────── + celery_broker_url: str = "redis://localhost:6379/0" + celery_result_backend: str = "redis://localhost:6379/1" + + # ── OSS 阿里云 ────────────────────────────────────────────────────── + oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com" + oss_access_key_id: str = "" + oss_access_key_secret: str = "" + oss_bucket_name: str = "xiaoxia-autocut" + oss_direct_upload_max_mb: int = 2000 + oss_direct_upload_expire_seconds: int = 900 + + # ── CosyVoice (阿里云百炼语音合成) ─────────────────────────────────── + cosyvoice_api_key: str = "" + cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1" + cosyvoice_model: str = "cosyvoice-v3-flash" + cosyvoice_voice: str = "longxiaochun_v3" # 默认音色(v3 系列系统音色带 _v3 后缀) + cosyvoice_sample_rate: int = 22050 + cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm + # 音色克隆模型名(固定为 voice-enrollment) + cosyvoice_clone_model: str = "voice-enrollment" + + # ── 豆包大模型(火山引擎方舟) ──────────────────────────────────────── + doubao_api_key: str = "" + doubao_model: str = "doubao-seed-1-6-250615" + doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3" + doubao_timeout: int = 30 + doubao_max_retries: int = 2 + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + +# ── 统一单例管理 ──────────────────────────────────────────────────────── +# 所有 Settings 类的单例缓存都在这里,消除每处各自实现的重复代码 + +_settings_cache: dict[str, BaseSettings] = {} + + +def _get_env_file() -> str: + """根据 APP_ENV 决定读取哪个 env 文件。""" + env = os.getenv("APP_ENV", "development") + env_file = f".env.{env}" if env != "development" else ".env" + return env_file if os.path.exists(env_file) else ".env" + + +def get_cached_settings(settings_class: Type[T], cache_key: Optional[str] = None) -> T: + """统一的 Settings 单例获取函数。 + + 所有服务都通过这个函数获取配置,消除重复的单例实现。 + 按类名缓存,同一类只初始化一次。 + """ + key = cache_key or settings_class.__name__ + if key not in _settings_cache: + env_file = _get_env_file() + _settings_cache[key] = settings_class(_env_file=env_file) + return _settings_cache[key] # type: ignore[return-value] + + +def reload_settings_cache() -> None: + """清空配置缓存,下次获取时重新加载。测试用。""" + _settings_cache.clear() + + +def get_shared_settings() -> SharedSettings: + """获取共享配置单例(统一入口)。""" + return get_cached_settings(SharedSettings) diff --git a/packages/config/worker_settings.py b/packages/config/worker_settings.py new file mode 100755 index 000000000..7e967a2a6 --- /dev/null +++ b/packages/config/worker_settings.py @@ -0,0 +1,44 @@ +"""Worker 服务配置 — 继承 SharedSettings,只追加 Worker 特有字段。 + +通用配置(DB/Redis/Celery/OSS/CosyVoice/Doubao 等)统一在 +packages/config/base.py 的 SharedSettings 中定义,这里不重复。 +""" + +from pydantic_settings import SettingsConfigDict + +from packages.config.base import SharedSettings, get_cached_settings + + +class WorkerSettings(SharedSettings): + """Worker 服务专用配置。 + + 通用配置继承自 SharedSettings,这里只定义 Worker 独有字段。 + Celery broker/backend 使用继承的 celery_broker_url / celery_result_backend; + 历史上 Worker 使用 broker_url / result_backend 字段名,通过 property 别名兼容。 + """ + + # ── Worker 特有 ──────────────────────────────────────────────────── + worker_name: str = "xiaoxia-saas-worker" + worker_concurrency: int = 4 + worker_max_tasks_per_child: int = 1000 + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # ── 向后兼容:Celery 字段名别名 ────────────────────────────────── + @property + def broker_url(self) -> str: + return self.celery_broker_url + + @property + def result_backend(self) -> str: + return self.celery_result_backend + + +def get_worker_settings() -> WorkerSettings: + """获取 Worker 配置单例(统一入口)。""" + return get_cached_settings(WorkerSettings) diff --git a/packages/shared/config.py b/packages/shared/config.py index 000fadc86..535d772ee 100755 --- a/packages/shared/config.py +++ b/packages/shared/config.py @@ -1,90 +1,11 @@ -"""统一配置入口 — API 和 Worker 共享的基础配置。 +"""向后兼容层 — 统一配置已迁移到 packages.config。 -所有服务通用配置定义在这里,两端各自的 Settings 类继承本类, -只追加服务特有字段。彻底消除重复定义和默认值不一致问题。 +新代码请直接使用: + from packages.config import SharedSettings, get_shared_settings + +本文件保留仅为兼容旧的 import 路径。 """ -import os -from typing import Optional +from packages.config import SharedSettings, get_shared_settings, get_cached_settings -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class SharedSettings(BaseSettings): - """所有服务共享的基础配置。 - - API 和 Worker 都继承本类,确保: - 1. 数据库/Redis/OSS/Celery 等核心配置默认值一致 - 2. 环境变量命名统一(小写风格,pydantic-settings 自动兼容大写) - 3. 单例模式和 env 文件加载逻辑只实现一次 - """ - - # ── 环境 ────────────────────────────────────────────────────────────── - environment: str = "development" - debug: bool = True - auto_create_schema: bool = False - - # ── 数据库 ──────────────────────────────────────────────────────────── - database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas" - database_pool_size: int = 20 - database_max_overflow: int = 10 # pool_size(20) + max_overflow(10) = 最大30连接 - database_pool_timeout: int = 30 - database_pool_recycle: int = 3600 - - # ── Redis ──────────────────────────────────────────────────────────── - redis_url: str = "redis://localhost:6379/0" - - # ── Celery ─────────────────────────────────────────────────────────── - celery_broker_url: str = "redis://localhost:6379/0" - celery_result_backend: str = "redis://localhost:6379/1" - - # ── OSS 阿里云 ────────────────────────────────────────────────────── - oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com" - oss_access_key_id: str = "" - oss_access_key_secret: str = "" - oss_bucket_name: str = "xiaoxia-autocut" - oss_direct_upload_max_mb: int = 2000 - oss_direct_upload_expire_seconds: int = 900 - - # ── CosyVoice (阿里云百炼语音合成) ─────────────────────────────────── - cosyvoice_api_key: str = "" - cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1" - cosyvoice_model: str = "cosyvoice-v3-flash" - cosyvoice_voice: str = "longxiaochun_v3" # 默认音色(v3 系列系统音色带 _v3 后缀) - cosyvoice_sample_rate: int = 22050 - cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm - # 音色克隆模型名(固定为 voice-enrollment) - cosyvoice_clone_model: str = "voice-enrollment" - - # ── 豆包大模型(火山引擎方舟) ──────────────────────────────────────── - doubao_api_key: str = "" - doubao_model: str = "doubao-seed-1-6-250615" - doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3" - doubao_timeout: int = 30 - doubao_max_retries: int = 2 - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore", - ) - - -_settings: Optional[SharedSettings] = None - - -def get_shared_settings() -> SharedSettings: - """获取共享配置单例。 - - 优先读取 APP_ENV 指定的环境文件(.env.{env}),不存在则读 .env。 - """ - global _settings - if _settings is None: - env = os.getenv("APP_ENV", "development") - env_file = f".env.{env}" if env != "development" else ".env" - if os.path.exists(env_file): - _settings = SharedSettings(_env_file=env_file) - else: - _settings = SharedSettings() - return _settings +__all__ = ["SharedSettings", "get_shared_settings", "get_cached_settings"] diff --git a/tests/unit/test_oss_direct_upload.py b/tests/unit/test_oss_direct_upload.py old mode 100644 new mode 100755 index 9f9f7152b..43989e35f --- a/tests/unit/test_oss_direct_upload.py +++ b/tests/unit/test_oss_direct_upload.py @@ -5,15 +5,14 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) -import app.config as app_config from app.core.storage import OSSStorageService -import packages.shared.config as shared_config +from packages.config import reload_settings_cache def _reset_settings() -> None: - app_config._settings = None - shared_config._settings = None + """清空所有配置缓存,让测试设置的环境变量生效。""" + reload_settings_cache() def test_create_direct_upload_post_limits_key_and_size(monkeypatch): diff --git a/tests/unit/test_p02_oss_credential_validation.py b/tests/unit/test_p02_oss_credential_validation.py old mode 100644 new mode 100755 index abf67590d..2185830a1 --- a/tests/unit/test_p02_oss_credential_validation.py +++ b/tests/unit/test_p02_oss_credential_validation.py @@ -15,23 +15,21 @@ import pytest def _fresh_settings(env: str): - """清除 config 模块缓存,以指定 APP_ENV 重新导入 Settings。 + """清除配置缓存,以指定 APP_ENV 重新创建设置实例。 - 为非开发环境预设 OSS 环境变量,确保模块级 get_settings() 能成功完成导入。 + 为非开发环境预设 OSS 环境变量,确保实例化能成功完成。 测试方法内可根据需要清除这些变量来测试验证器。 """ - for mod_name in [m for m in list(sys.modules) if "app.config" in m]: - del sys.modules[mod_name] + import os os.environ["APP_ENV"] = env # 非开发环境下,为模块级导入提供有效凭证(避免导入时验证失败) if env != "development": os.environ.setdefault("OSS_ACCESS_KEY_ID", "test-key-for-import") os.environ.setdefault("OSS_ACCESS_KEY_SECRET", "test-secret-for-import") - # 重置单例,让测试方法自行控制实例化 - from apps.api.app import config as _cfg - from apps.api.app.config import Settings + # 清空统一配置缓存,让测试方法自行控制实例化 + from packages.config import reload_settings_cache, APISettings as Settings - _cfg._settings = None + reload_settings_cache() return Settings