From d92909321c3a5ae38bf668f7e8fca9d8012d51ec Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 23 Jul 2026 23:54:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?refactor(#777):=20=E4=BF=AE=E5=A4=8DDomain?= =?UTF-8?q?=E5=B1=82=E4=BE=9D=E8=B5=96Adapters=E7=9A=84DDD=E8=BF=9D?= =?UTF-8?q?=E8=A7=84=EF=BC=884=E5=A4=84=EF=BC=89=20(#789)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/adapters/redis/session_store.py | 4 +- packages/adapters/smtp/email_service.py | 18 +---- packages/application/auth/jwt_service.py | 4 +- packages/application/auth/password_hasher.py | 8 ++- packages/domain/auth/__init__.py | 52 +++++++-------- packages/domain/auth/email_service.py | 64 ++++++++++++++++-- packages/domain/auth/jwt_service.py | 44 +++++++++++++ packages/domain/auth/password_hasher.py | 38 +++++++++++ packages/domain/auth/session_store.py | 69 ++++++++++++++++++-- tests/unit/test_email_service.py | 2 +- tests/unit/test_session_store.py | 2 +- 11 files changed, 242 insertions(+), 63 deletions(-) mode change 100644 => 100755 packages/adapters/smtp/email_service.py mode change 100644 => 100755 packages/application/auth/jwt_service.py mode change 100644 => 100755 packages/application/auth/password_hasher.py mode change 100644 => 100755 packages/domain/auth/__init__.py mode change 100644 => 100755 packages/domain/auth/email_service.py create mode 100755 packages/domain/auth/jwt_service.py create mode 100755 packages/domain/auth/password_hasher.py mode change 100644 => 100755 packages/domain/auth/session_store.py mode change 100644 => 100755 tests/unit/test_email_service.py mode change 100644 => 100755 tests/unit/test_session_store.py diff --git a/packages/adapters/redis/session_store.py b/packages/adapters/redis/session_store.py index e252a38a3..9c8815e4c 100755 --- a/packages/adapters/redis/session_store.py +++ b/packages/adapters/redis/session_store.py @@ -10,6 +10,8 @@ from typing import Optional import redis from redis import Redis +from packages.domain.auth.session_store import SessionStorePort + class RedisConfig: """Redis 配置""" @@ -50,7 +52,7 @@ class NoopSessionStore: return False -class SessionStore: +class SessionStore(SessionStorePort): """Session 存储服务""" def __init__(self, redis_client: Optional[Redis] = None, config: Optional[RedisConfig] = None): diff --git a/packages/adapters/smtp/email_service.py b/packages/adapters/smtp/email_service.py old mode 100644 new mode 100755 index df55dca18..bd8804c81 --- a/packages/adapters/smtp/email_service.py +++ b/packages/adapters/smtp/email_service.py @@ -4,11 +4,12 @@ """ import smtplib -from dataclasses import dataclass from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import List, Optional +from packages.domain.auth.email_service import EmailConfig, EmailServicePort + class NoopEmailService: def send_verification_email(self, **kwargs): @@ -18,20 +19,7 @@ class NoopEmailService: return False, "Email delivery is disabled" -@dataclass -class EmailConfig: - """邮件配置""" - - smtp_host: str = "smtp.gmail.com" - smtp_port: int = 587 - smtp_user: str = "" - smtp_password: str = "" - from_email: str = "" - from_name: str = "小虾 SaaS" - use_tls: bool = True - - -class EmailService: +class EmailService(EmailServicePort): """邮件服务类""" def __init__(self, config: Optional[EmailConfig] = None): diff --git a/packages/application/auth/jwt_service.py b/packages/application/auth/jwt_service.py old mode 100644 new mode 100755 index 5feb7dc07..2a08bc450 --- a/packages/application/auth/jwt_service.py +++ b/packages/application/auth/jwt_service.py @@ -6,6 +6,8 @@ from typing import Any, Dict, Optional import jwt from jwt.exceptions import ExpiredSignatureError, InvalidTokenError +from packages.domain.auth.jwt_service import JWTServicePort + class JWTConfig: """JWT 配置""" @@ -57,7 +59,7 @@ class TokenType: REFRESH = "refresh" -class JWTService: +class JWTService(JWTServicePort): """JWT 服务类""" def __init__(self, config: JWTConfig = None): diff --git a/packages/application/auth/password_hasher.py b/packages/application/auth/password_hasher.py old mode 100644 new mode 100755 index 911a6be88..d9ec60c9f --- a/packages/application/auth/password_hasher.py +++ b/packages/application/auth/password_hasher.py @@ -3,12 +3,14 @@ 使用 bcrypt 安全存储密码 """ -from typing import Optional +from typing import Optional, Tuple import bcrypt +from packages.domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort -class PasswordHasher: + +class PasswordHasher(PasswordHasherPort): """密码哈希服务""" def __init__(self, rounds: int = 12): @@ -98,7 +100,7 @@ class PasswordHasher: return False -class PasswordValidator: +class PasswordValidator(PasswordValidatorPort): """密码强度验证器""" def __init__( diff --git a/packages/domain/auth/__init__.py b/packages/domain/auth/__init__.py old mode 100644 new mode 100755 index ce8c4b53d..ca8d31202 --- a/packages/domain/auth/__init__.py +++ b/packages/domain/auth/__init__.py @@ -1,36 +1,30 @@ -"""Authentication domain services. +"""领域层认证相关端口接口. -Only pure domain authentication helpers are exported here. Infrastructure-backed -services such as Redis session storage and SMTP email delivery live under -`packages.adapters` and should be injected into use cases. +遵循 DDD 依赖倒置原则:领域层定义端口(抽象接口), +外层(应用层、基础设施层)实现端口。 + +使用方式: + from packages.domain.auth import EmailServicePort, SessionStorePort + from packages.domain.auth import JWTServicePort, PasswordHasherPort + from packages.domain.auth.sms_service import SmsService + +注意:具体实现请从对应的适配器层导入: + - 邮件服务: from packages.adapters.smtp import EmailService + - Session存储: from packages.adapters.redis import SessionStore + - JWT服务: from packages.application.auth.jwt_service import JWTService + - 密码哈希: from packages.application.auth.password_hasher import PasswordHasher """ -from packages.application.auth.jwt_service import ( - JWTConfig, - JWTService, - TokenType, - jwt_service, -) -from packages.application.auth.password_hasher import ( - PasswordHasher, - PasswordValidator, - password_hasher, - password_validator, -) -from packages.domain.auth.email_service import EmailConfig, EmailService -from packages.domain.auth.session_store import RedisConfig, SessionStore +from packages.domain.auth.email_service import EmailConfig, EmailServicePort +from packages.domain.auth.jwt_service import JWTServicePort +from packages.domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort +from packages.domain.auth.session_store import SessionStorePort __all__ = [ - "JWTService", - "JWTConfig", - "TokenType", - "jwt_service", - "PasswordHasher", - "PasswordValidator", - "password_hasher", - "password_validator", - "SessionStore", - "RedisConfig", - "EmailService", "EmailConfig", + "EmailServicePort", + "JWTServicePort", + "PasswordHasherPort", + "PasswordValidatorPort", + "SessionStorePort", ] diff --git a/packages/domain/auth/email_service.py b/packages/domain/auth/email_service.py old mode 100644 new mode 100755 index b2e9ad0ae..b1ace6460 --- a/packages/domain/auth/email_service.py +++ b/packages/domain/auth/email_service.py @@ -1,10 +1,62 @@ -"""Compatibility import for SMTP email delivery. +"""邮件服务端口(领域层接口). -Infrastructure implementations live under `packages.adapters`. New code should -import `packages.adapters.smtp.email_service` directly or inject an email-sender -port into the use case. +定义邮件发送服务的抽象接口,具体实现由基础设施层(adapters)提供。 +遵循 DDD 依赖倒置原则:领域层定义端口,外层实现端口。 """ -from packages.adapters.smtp.email_service import EmailConfig, EmailService +from __future__ import annotations -__all__ = ["EmailConfig", "EmailService"] +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class EmailConfig: + """邮件配置(领域层值对象)""" + + smtp_host: str = "smtp.gmail.com" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + from_email: str = "" + from_name: str = "小虾 SaaS" + use_tls: bool = True + + +class EmailServicePort(ABC): + """邮件服务端口(抽象接口)""" + + @abstractmethod + def send_email( + self, + to_email: str, + subject: str, + html_body: str, + text_body: Optional[str] = None, + cc: Optional[List[str]] = None, + bcc: Optional[List[str]] = None, + ) -> tuple[bool, Optional[str]]: + """发送邮件""" + ... + + @abstractmethod + def send_verification_email( + self, + to_email: str, + verification_code: str, + username: str = "", + ) -> tuple[bool, Optional[str]]: + """发送验证邮件""" + ... + + @abstractmethod + def send_password_reset_email( + self, + to_email: str, + reset_token: str, + reset_url: str = "", + username: str = "", + ) -> tuple[bool, Optional[str]]: + """发送密码重置邮件""" + ... diff --git a/packages/domain/auth/jwt_service.py b/packages/domain/auth/jwt_service.py new file mode 100755 index 000000000..29caf1cb1 --- /dev/null +++ b/packages/domain/auth/jwt_service.py @@ -0,0 +1,44 @@ +"""JWT 服务端口(领域层接口). + +定义 JWT Token 生成、验证、解析的抽象接口,具体实现由应用层或基础设施层提供。 +遵循 DDD 依赖倒置原则:领域层定义端口,外层实现端口。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict + + +class JWTServicePort(ABC): + """JWT 服务端口(抽象接口)""" + + @abstractmethod + def create_access_token( + self, + user_id: str, + role: str = "", + additional_claims: Dict[str, Any] | None = None, + ) -> str: + """创建 access_token""" + ... + + @abstractmethod + def create_refresh_token(self, user_id: str, session_id: str) -> str: + """创建 refresh_token""" + ... + + @abstractmethod + def verify_token(self, token: str) -> Dict[str, Any]: + """验证任意 Token""" + ... + + @abstractmethod + def verify_access_token(self, token: str) -> Dict[str, Any]: + """验证 access_token""" + ... + + @abstractmethod + def verify_refresh_token(self, token: str) -> Dict[str, Any]: + """验证 refresh_token""" + ... diff --git a/packages/domain/auth/password_hasher.py b/packages/domain/auth/password_hasher.py new file mode 100755 index 000000000..bc59ff19d --- /dev/null +++ b/packages/domain/auth/password_hasher.py @@ -0,0 +1,38 @@ +"""密码哈希服务端口(领域层接口). + +定义密码哈希与验证的抽象接口,具体实现由应用层或基础设施层提供。 +遵循 DDD 依赖倒置原则:领域层定义端口,外层实现端口。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Optional, Tuple + + +class PasswordHasherPort(ABC): + """密码哈希服务端口(抽象接口)""" + + @abstractmethod + def hash_password(self, password: str) -> str: + """哈希密码""" + ... + + @abstractmethod + def verify_password(self, password: str, hashed_password: str) -> bool: + """验证密码""" + ... + + @abstractmethod + def needs_rehash(self, hashed_password: str) -> bool: + """检查哈希是否需要重新计算""" + ... + + +class PasswordValidatorPort(ABC): + """密码强度验证端口(抽象接口)""" + + @abstractmethod + def validate(self, password: str) -> Tuple[bool, Optional[str]]: + """验证密码强度""" + ... diff --git a/packages/domain/auth/session_store.py b/packages/domain/auth/session_store.py old mode 100644 new mode 100755 index bb65a83f3..852f7ce6a --- a/packages/domain/auth/session_store.py +++ b/packages/domain/auth/session_store.py @@ -1,10 +1,67 @@ -"""Compatibility import for Redis-backed session storage. +"""Session 存储端口(领域层接口). -Infrastructure implementations live under `packages.adapters`. New code should -import `packages.adapters.redis.session_store` directly or inject a session-store -port into the use case. +定义 Session 存储服务的抽象接口,具体实现由基础设施层(adapters)提供。 +遵循 DDD 依赖倒置原则:领域层定义端口,外层实现端口。 """ -from packages.adapters.redis.session_store import RedisConfig, SessionStore +from __future__ import annotations -__all__ = ["RedisConfig", "SessionStore"] +from abc import ABC, abstractmethod +from typing import Optional + + +class SessionStorePort(ABC): + """Session 存储端口(抽象接口)""" + + @abstractmethod + def save_session( + self, + session_id: str, + user_id: str, + refresh_token: str, + user_agent: str = "", + ip_address: str = "", + expires_in_days: int = 7, + ) -> bool: + """保存 Session""" + ... + + @abstractmethod + def get_session(self, session_id: str) -> Optional[dict]: + """获取 Session""" + ... + + @abstractmethod + def get_session_by_refresh_token(self, refresh_token: str) -> Optional[dict]: + """通过 refresh_token 查找 Session""" + ... + + @abstractmethod + def get_refresh_token(self, session_id: str) -> Optional[str]: + """获取 refresh_token""" + ... + + @abstractmethod + def update_last_active(self, session_id: str) -> bool: + """更新最后活跃时间""" + ... + + @abstractmethod + def delete_session(self, session_id: str) -> bool: + """删除 Session""" + ... + + @abstractmethod + def get_user_sessions(self, user_id: str) -> list[dict]: + """获取用户所有 Session""" + ... + + @abstractmethod + def delete_all_user_sessions(self, user_id: str) -> int: + """删除用户所有 Session""" + ... + + @abstractmethod + def session_exists(self, session_id: str) -> bool: + """检查 Session 是否存在""" + ... diff --git a/tests/unit/test_email_service.py b/tests/unit/test_email_service.py old mode 100644 new mode 100755 index 228d1c3ff..c9002b8a9 --- a/tests/unit/test_email_service.py +++ b/tests/unit/test_email_service.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from packages.domain.auth.email_service import EmailConfig, EmailService +from packages.adapters.smtp.email_service import EmailConfig, EmailService class TestEmailService: diff --git a/tests/unit/test_session_store.py b/tests/unit/test_session_store.py old mode 100644 new mode 100755 index 389aaedd2..0bf0dca32 --- a/tests/unit/test_session_store.py +++ b/tests/unit/test_session_store.py @@ -7,7 +7,7 @@ from unittest.mock import Mock import pytest -from packages.domain.auth.session_store import SessionStore +from packages.adapters.redis.session_store import SessionStore class TestSessionStore: From c6941a738c8cf2bdca9b839f172cd327f8378871 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 23 Jul 2026 23:54:59 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(#780):=20=E8=A1=A5=E9=BD=90.env.exampl?= =?UTF-8?q?e=E7=BC=BA=E5=A4=B1=E7=9A=8430+=E9=85=8D=E7=BD=AE=E9=A1=B9=20(#?= =?UTF-8?q?790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 222 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 180 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 60efca990..01139df98 100755 --- a/.env.example +++ b/.env.example @@ -1,60 +1,198 @@ -# 小虾 SaaS 环境变量配置 +# ============================================================ +# 小虾 SaaS 环境变量完整配置 +# ============================================================ +# 本文件列出所有可配置的环境变量及默认值。 +# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。 +# +# 配置读取规则(pydantic-settings,大小写不敏感): +# 1. 系统环境变量(最高优先级) +# 2. .env.{APP_ENV} 文件(如 .env.staging) +# 3. .env 文件 +# 4. 代码中的默认值(最低优先级) +# ============================================================ -# ==================== 应用配置 ==================== -APP_NAME=小虾 SaaS -APP_BASE_URL=http://localhost:3000 + +# ==================== 应用基本配置 ==================== + +# 应用名称 +APP_NAME=xiaoxia-saas + +# 应用版本号(展示用,代码中已内置默认) +APP_VERSION=0.1.61 + +# 环境标识:development / staging / production +# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验 APP_ENV=development -# ==================== 数据库配置 ==================== -DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas - -# 开发环境:使用内存数据库(不需要 PostgreSQL) -USE_IN_MEMORY_DB=true - -# 生产环境:使用 PostgreSQL -# USE_IN_MEMORY_DB=false - -# ==================== Redis 配置 ==================== -REDIS_URL=redis://localhost:6379/0 - -# ==================== JWT 配置 ==================== -JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars -JWT_ALGORITHM=HS256 -JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 -JWT_REFRESH_TOKEN_EXPIRE_DAYS=30 - -# ==================== 邮件配置 ==================== -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USER=your-email@gmail.com -SMTP_PASSWORD=your-app-specific-password -SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com -SMTP_FROM_NAME=小虾 SaaS - -# ==================== 环境配置 ==================== -ENVIRONMENT=development +# 是否开启 Debug 模式(开发环境 true,生产环境 false) DEBUG=true -# ==================== CORS 配置 ==================== -# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW) -CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173 +# 应用基础 URL,用于生成认证邮件、回调链接等 +APP_BASE_URL=http://localhost:3000 + +# API 服务监听地址(容器内绑定,外部暴露由 Docker/Nginx 控制) +API_HOST=0.0.0.0 + +# API 服务监听端口 +API_PORT=8000 + +# 是否自动创建数据库表结构(开发环境可开启,生产环境用 alembic migration) +AUTO_CREATE_SCHEMA=false + + +# ==================== 数据库配置 ==================== + +# 数据库连接串(格式:postgresql+psycopg://user:password@host:port/dbname) +DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas + +# 连接池大小(常驻连接数) +DATABASE_POOL_SIZE=20 + +# 连接池最大溢出连接数(pool_size + max_overflow = 最大并发连接数) +DATABASE_MAX_OVERFLOW=10 + +# 获取连接超时时间(秒) +DATABASE_POOL_TIMEOUT=30 + +# 连接回收时间(秒),防止数据库端主动断开导致的死连接 +DATABASE_POOL_RECYCLE=3600 + +# 是否使用内存数据库(SQLite,仅开发/测试可用;生产务必 false) +USE_IN_MEMORY_DB=false + + +# ==================== Redis 配置 ==================== + +# Redis 连接 URL(格式:redis://[:password@]host:port/db) +REDIS_URL=redis://localhost:6379/0 + +# 是否使用 Redis 存储 Session(多实例部署时必须开启;开发可用内存存储) +ENABLE_REDIS_SESSIONS=false + + +# ==================== Celery 任务队列 ==================== + +# Celery Broker(任务分发),默认用 Redis db0 +CELERY_BROKER_URL=redis://localhost:6379/0 + +# Celery Result Backend(任务结果存储),默认用 Redis db1 +CELERY_RESULT_BACKEND=redis://localhost:6379/1 + + +# ==================== Worker 配置 ==================== + +# Worker 进程名称 +WORKER_NAME=xiaoxia-saas-worker + +# Worker 并发数(同时执行的任务数) +WORKER_CONCURRENCY=4 + +# 每个子进程最多处理多少任务后重启(防止内存泄漏) +WORKER_MAX_TASKS_PER_CHILD=1000 + + +# ==================== JWT 认证配置 ==================== + +# JWT 签名密钥 — 生产环境必须设置为强随机字符串(至少32字符) +# 内置不安全值会被拒绝:secret / changeme / password / your-secret-key 等 +JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars + +# JWT 签名算法 +JWT_ALGORITHM=HS256 + +# Access Token 过期时间(分钟) +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# Refresh Token 过期时间(天) +JWT_REFRESH_TOKEN_EXPIRE_DAYS=30 + + +# ==================== 邮件配置 ==================== + +# 是否启用邮件投递(关闭时邮件内容打印到日志,开发调试用) +ENABLE_EMAIL_DELIVERY=false + +# SMTP 服务器地址 +SMTP_HOST=smtp.gmail.com + +# SMTP 端口 +SMTP_PORT=587 + +# SMTP 用户名 +SMTP_USER=your-email@gmail.com + +# SMTP 密码 / 应用专用密码 +SMTP_PASSWORD=your-app-specific-password + +# 发件人邮箱 +SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com + +# 发件人显示名称 +SMTP_FROM_NAME=小虾 SaaS + +# 是否启用 TLS +SMTP_USE_TLS=true + # ==================== 阿里云 OSS 配置 ==================== + +# OSS 区域 endpoint OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com + +# OSS Access Key ID — 非开发环境必须设置 OSS_ACCESS_KEY_ID=your-access-key-id + +# OSS Access Key Secret — 非开发环境必须设置 OSS_ACCESS_KEY_SECRET=your-access-key-secret + +# OSS Bucket 名称 OSS_BUCKET_NAME=xiaoxia-autocut -# ==================== CosyVoice 语音合成配置 ==================== -# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接 -# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高) -# cosyvoice-v3-plus (高质量,系统音色少) -# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色) -# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀) -# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取 +# 直传最大文件大小(MB) +OSS_DIRECT_UPLOAD_MAX_MB=2000 + +# 直传签名有效期(秒) +OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900 + + +# ==================== CORS 配置 ==================== + +# 允许跨域的前端域名列表,逗号分隔 +CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173,http://localhost:8000 + + +# ==================== 渲染引擎配置 ==================== + +# 渲染引擎选择: +# legacy — 旧 VideoComposeService(稳定,功能完整) +# unified — 新 UnifiedRenderService(新架构,部分场景仍在验证) +RENDER_ENGINE=legacy + + +# ==================== CosyVoice 语音合成 ==================== +# 阿里云百灵语音合成服务 +# 模型选择: +# cosyvoice-v3-flash — 推荐,系统音色多,性价比高 +# cosyvoice-v3-plus — 高质量,系统音色少 +# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus — 仅支持克隆/设计音色,无系统音色 +# 音色:v3 系列系统音色带 _v3 后缀,如 longxiaochun_v3 / longxiaoxia_v3 / longanyang + COSYVOICE_API_KEY=your-cosyvoice-api-key COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1 COSYVOICE_MODEL=cosyvoice-v3-flash COSYVOICE_VOICE=longxiaochun_v3 COSYVOICE_SAMPLE_RATE=22050 COSYVOICE_FORMAT=mp3 + +# 音色克隆模型名(固定为 voice-enrollment,通常不需修改) +COSYVOICE_CLONE_MODEL=voice-enrollment + + +# ==================== 豆包大模型(火山引擎方舟) ==================== +# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景 + +DOUBAO_API_KEY=your-doubao-api-key +DOUBAO_MODEL=doubao-seed-1-6-250615 +DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +DOUBAO_TIMEOUT=30 +DOUBAO_MAX_RETRIES=2 From ed21ee11d220b94281f508942b0d9bf241dab287 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 23 Jul 2026 23:55:01 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(#776):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=85=A5=E5=8F=A3=EF=BC=8C=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?packages/config=E6=B6=88=E9=99=A4=E9=87=8D=E5=A4=8D=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20(#791)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/env.py | 11 +- apps/api/app/config.py | 322 ++---------------- apps/api/app/core/config.py | 19 +- apps/worker/worker_app/core/config.py | 65 +--- packages/config/__init__.py | 34 ++ packages/config/api_settings.py | 288 ++++++++++++++++ packages/config/base.py | 110 ++++++ packages/config/worker_settings.py | 44 +++ packages/shared/config.py | 93 +---- tests/unit/test_oss_direct_upload.py | 7 +- .../test_p02_oss_credential_validation.py | 14 +- 11 files changed, 543 insertions(+), 464 deletions(-) mode change 100644 => 100755 alembic/env.py mode change 100644 => 100755 apps/api/app/core/config.py create mode 100755 packages/config/__init__.py create mode 100755 packages/config/api_settings.py create mode 100755 packages/config/base.py create mode 100755 packages/config/worker_settings.py mode change 100644 => 100755 tests/unit/test_oss_direct_upload.py mode change 100644 => 100755 tests/unit/test_p02_oss_credential_validation.py 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 From f21d981585be0197daf413a359244051c96cdba9 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 15:57:09 +0000 Subject: [PATCH 4/4] style: auto-format with black + isort + prettier --- apps/api/app/config.py | 1 + packages/config/api_settings.py | 6 ++---- packages/shared/config.py | 2 +- tests/unit/test_p02_oss_credential_validation.py | 4 +++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 1749c0ed5..b94bc54a2 100755 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -15,6 +15,7 @@ from packages.config import reload_settings_cache # 模块级单例(向后兼容) settings = get_settings() + # 暴露旧的 reload_settings 函数名 def reload_settings(): """重新加载配置(测试用)。""" diff --git a/packages/config/api_settings.py b/packages/config/api_settings.py index a6192aeee..529ed76fb 100755 --- a/packages/config/api_settings.py +++ b/packages/config/api_settings.py @@ -51,8 +51,7 @@ class APISettings(SharedSettings): 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!" + "JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!" ) # Block known insecure default values insecure_defaults = [ @@ -64,8 +63,7 @@ class APISettings(SharedSettings): ] 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." + f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable." ) return v diff --git a/packages/shared/config.py b/packages/shared/config.py index 535d772ee..ceebe8bbe 100755 --- a/packages/shared/config.py +++ b/packages/shared/config.py @@ -6,6 +6,6 @@ 本文件保留仅为兼容旧的 import 路径。 """ -from packages.config import SharedSettings, get_shared_settings, get_cached_settings +from packages.config import SharedSettings, get_cached_settings, get_shared_settings __all__ = ["SharedSettings", "get_shared_settings", "get_cached_settings"] diff --git a/tests/unit/test_p02_oss_credential_validation.py b/tests/unit/test_p02_oss_credential_validation.py index 2185830a1..e9b88bef7 100755 --- a/tests/unit/test_p02_oss_credential_validation.py +++ b/tests/unit/test_p02_oss_credential_validation.py @@ -21,13 +21,15 @@ def _fresh_settings(env: str): 测试方法内可根据需要清除这些变量来测试验证器。 """ 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 packages.config import reload_settings_cache, APISettings as Settings + from packages.config import APISettings as Settings + from packages.config import reload_settings_cache reload_settings_cache() return Settings