refactor(#776): 统一配置入口,建立packages/config消除重复定义 #791
Regular → Executable
+7
-4
@@ -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.
|
||||
|
||||
+21
-301
@@ -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"]
|
||||
|
||||
Regular → Executable
+10
-9
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Executable
+34
@@ -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",
|
||||
]
|
||||
Executable
+288
@@ -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)
|
||||
Executable
+110
@@ -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)
|
||||
Executable
+44
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
Regular → Executable
+3
-4
@@ -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):
|
||||
|
||||
Regular → Executable
+6
-8
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user