diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 index 3363ab94a..dc55a01ae --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # ==================== 应用配置 ==================== APP_NAME=小虾 SaaS -BASE_URL=http://localhost:3000 +APP_BASE_URL=http://localhost:3000 # ==================== 数据库配置 ==================== DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas diff --git a/.env.production b/.env.production old mode 100644 new mode 100755 index 341ce6ed9..19aeb8102 --- a/.env.production +++ b/.env.production @@ -45,12 +45,13 @@ CELERY_WORKER_MAX_TASKS_PER_CHILD=1000 # ======================= # MinIO 对象存储配置 # ======================= -MINIO_ENDPOINT=47.98.113.167:9000 -MINIO_ACCESS_KEY=CHANGE_ME_PRODUCTION -MINIO_SECRET_KEY=CHANGE_ME_PRODUCTION -MINIO_BUCKET=xiaoxia-assets -MINIO_SECURE=false -MINIO_PUBLIC_URL=http://47.98.113.167:9000 +# OSS 对象存储配置 (原 MINIO_* 配置已统一为 OSS_* 命名) +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +OSS_ACCESS_KEY_ID=CHANGE_ME_PRODUCTION +OSS_ACCESS_KEY_SECRET=CHANGE_ME_PRODUCTION +OSS_BUCKET_NAME=xiaoxia-assets +OSS_SECURE=false +OSS_PUBLIC_URL=http://47.98.113.167:9000 # ======================= # 日志配置 diff --git a/.env.staging b/.env.staging old mode 100644 new mode 100755 index 3775ad9c5..5472c3ddd --- a/.env.staging +++ b/.env.staging @@ -45,12 +45,13 @@ CELERY_WORKER_MAX_TASKS_PER_CHILD=1000 # ======================= # MinIO 对象存储配置 # ======================= -MINIO_ENDPOINT=47.98.113.167:9000 -MINIO_ACCESS_KEY=CHANGE_ME_STAGING -MINIO_SECRET_KEY=CHANGE_ME_STAGING -MINIO_BUCKET=xiaoxia-assets -MINIO_SECURE=false -MINIO_PUBLIC_URL=http://47.98.113.167:9000 +# OSS 对象存储配置 (原 MINIO_* 配置已统一为 OSS_* 命名) +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +OSS_ACCESS_KEY_ID=CHANGE_ME_STAGING +OSS_ACCESS_KEY_SECRET=CHANGE_ME_STAGING +OSS_BUCKET_NAME=xiaoxia-assets +OSS_SECURE=false +OSS_PUBLIC_URL=http://47.98.113.167:9000 # ======================= # 日志配置 diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py old mode 100644 new mode 100755 index 482b566ae..c3bf6c956 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -93,7 +93,7 @@ async def register( ): use_case = RegisterUserUseCase( user_repository=user_repository, - base_url="http://localhost:3000", + base_url=settings.APP_BASE_URL, email_service=email_service, ) response, error = use_case.execute( diff --git a/apps/api/app/config.py b/apps/api/app/config.py old mode 100644 new mode 100755 index f7258b386..b587cf4cc --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -11,6 +11,11 @@ class Settings(BaseSettings): ENVIRONMENT: str = "development" DEBUG: bool = True + # 应用基础 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 @@ -20,7 +25,7 @@ class Settings(BaseSettings): "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas" ) DATABASE_POOL_SIZE: int = 20 - DATABASE_MAX_OVERFLOW: int = 40 + DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接 DATABASE_POOL_TIMEOUT: int = 30 DATABASE_POOL_RECYLE: int = 3600 USE_IN_MEMORY_DB: bool = False @@ -32,6 +37,11 @@ class Settings(BaseSettings): # JWT secret key - MUST be set via environment variable, no default allowed JWT_SECRET_KEY: Optional[str] = None + # 旧的 JWT secret key(用于密钥轮换期间验证旧 token) + # 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期 + JWT_SECRET_KEY_OLD: Optional[str] = None + # 密钥轮换天数(到达此天数后建议更换密钥) + SECRET_ROTATION_DAYS: int = 90 @field_validator("JWT_SECRET_KEY", mode="before") @classmethod diff --git a/docs/KEY-ROTATION.md b/docs/KEY-ROTATION.md new file mode 100755 index 000000000..6b237bcfc --- /dev/null +++ b/docs/KEY-ROTATION.md @@ -0,0 +1,109 @@ +# 安全密钥轮换指南 + +本文档说明如何手动轮换小虾 SaaS 的安全密钥。 + +## 概述 + +为了保障系统安全,建议定期轮换以下密钥: +- `JWT_SECRET_KEY` - JWT 签名密钥 + +## 配置项 + +在 `apps/api/app/config.py` 中定义: + +```python +# JWT secret key - MUST be set via environment variable, no default allowed +JWT_SECRET_KEY: Optional[str] = None +``` + +## 轮换流程 + +### 1. 准备新密钥 + +生成一个新的强随机密钥(至少 32 字符): + +```bash +# 使用 Python 生成 +python3 -c "import secrets; print(secrets.token_urlsafe(48))" + +# 或使用 openssl +openssl rand -base64 48 +``` + +### 2. 更新环境变量 + +在生产/预发布环境中更新环境变量文件: + +**`.env.production` / `.env.staging`**: + +```bash +# 旧密钥(保留用于平滑迁移期间验证旧 token) +JWT_SECRET_KEY_OLD=your-old-secret-key + +# 新密钥 +JWT_SECRET_KEY=your-new-secret-key +``` + +### 3. 通知用户(可选) + +如果需要用户重新登录,可以提前发布公告。 + +### 4. 部署新版本 + +```bash +# 使用 docker-compose 重启服务 +docker-compose down && docker-compose up -d + +# 或使用 K8s +kubectl rollout restart deployment/xiaoxia-api +``` + +### 5. 验证 + +- 检查服务是否正常启动 +- 验证新用户可以正常登录 +- 确认 API 请求正常工作 + +### 6. 清理旧密钥 + +在确认所有功能正常后,可以移除旧密钥: + +```bash +# 编辑 .env.production +# 删除 JWT_SECRET_KEY_OLD 行 +``` + +## 自动轮换支持 + +当前版本支持在环境变量中同时配置新旧密钥: + +```python +JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY") +JWT_SECRET_KEY_OLD: Optional[str] = os.getenv("JWT_SECRET_KEY_OLD") +``` + +这允许: +1. 先部署新密钥(旧 token 仍可用旧密钥验证) +2. 等待旧 token 自然过期 +3. 清理旧密钥 + +## 推荐的轮换周期 + +| 环境 | 建议轮换周期 | +|------|-------------| +| 生产环境 | 每 90 天 | +| 预发布环境 | 每 90 天 | +| 开发环境 | 按需 | + +## 注意事项 + +1. **不要在代码中硬编码密钥** - 始终使用环境变量 +2. **密钥强度** - 使用至少 32 字符的随机字符串 +3. **备份** - 在轮换前确保旧密钥有备份 +4. **监控** - 轮换后监控异常登录行为 +5. **零停机** - 推荐使用双密钥机制实现平滑迁移 + +## 相关文档 + +- [环境配置指南](./ENVIRONMENT-CONFIG.md) +- [生产部署清单](./PRODUCTION-CHECKLIST.md) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 129ee9b06..c317558db 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -63,7 +63,11 @@ class AssetModel(Base): asset_library_id = Column(String(36), nullable=False, index=True) name = Column(String(500), nullable=False) file_type = Column(String(20), nullable=False, index=True) - file_size = Column(Float, nullable=False) + # storage_key: OSS 对象键(相对路径),用于内部存储和操作 + storage_key = Column(String(255), nullable=False) + # file_size: 文件大小(字节),使用 Integer 类型以确保精确性 + file_size = Column(Integer, nullable=False) + # file_url: 完整可访问的 URL,用于客户端直接访问文件 file_url = Column(String(1000), nullable=False) thumbnail_url = Column(String(1000), nullable=True) duration = Column(Float, nullable=True) @@ -194,8 +198,10 @@ class GeneratedVideoModel(Base): project_id = Column(String(32), nullable=False, index=True) generation_task_id = Column(String(32), nullable=False, index=True) name = Column(String(255), nullable=False) + # file_url: 完整可访问的 URL,用于客户端直接访问视频 file_url = Column(String(1000), nullable=False) - file_size = Column(Float, nullable=False) + # file_size: 文件大小(字节),使用 Integer 类型以确保精确性 + file_size = Column(Integer, nullable=False) duration = Column(Float, nullable=False) thumbnail_url = Column(String(1000), nullable=True) width = Column(Float, nullable=False) diff --git a/packages/application/auth/login_use_case.py b/packages/application/auth/login_use_case.py old mode 100644 new mode 100755 index 15b9d263c..2cf852ad1 --- a/packages/application/auth/login_use_case.py +++ b/packages/application/auth/login_use_case.py @@ -248,20 +248,16 @@ class RefreshTokenUseCase: """ 通过 refresh_token 查找 session - 遍历所有 session 查找匹配的 refresh_token(生产环境应使用索引优化) + 使用 Redis 中的反向索引 (refresh_token -> session_id) 快速查找 session。 + 反向索引在 save_session 时创建,确保了 O(1) 的查找复杂度。 + + Args: + refresh_token: 刷新令牌 + + Returns: + Session 数据字典,包含 session_id, user_id 等信息;如果不存在返回 None """ - # Scan Redis for all session keys and check refresh_token - # This is a simplified implementation - in production, use a reverse index - # e.g., store refresh_token -> session_id mapping in Redis - - # For now, iterate through user sessions - # In a real implementation, you would maintain a reverse index: - # refresh_token:session_id -> session_id - # or use Redis SCAN to find sessions with matching refresh_token - - # Simplified: iterate users (not scalable for production) - # In production, add: session_store.save_refresh_token_index(refresh_token, session_id) - return None # Placeholder - implement with reverse index in production + return self.session_store.get_session_by_refresh_token(refresh_token) class LogoutRequest: diff --git a/packages/application/auth/password_reset_use_case.py b/packages/application/auth/password_reset_use_case.py old mode 100644 new mode 100755 index b5803757c..08efaeb0a --- a/packages/application/auth/password_reset_use_case.py +++ b/packages/application/auth/password_reset_use_case.py @@ -23,10 +23,19 @@ class RequestPasswordResetUseCase: def __init__( self, user_repository, - base_url: str = "http://localhost:3000", + base_url: str, token_expire_hours: int = 1, email_service=None, ): + """ + 初始化请求密码重置用例 + + Args: + user_repository: 用户仓储 + base_url: 应用基础 URL(用于生成重置链接),必须由调用方传入 + token_expire_hours: 令牌过期时间(小时) + email_service: 邮件服务 + """ self.user_repository = user_repository self.base_url = base_url self.token_expire_hours = token_expire_hours diff --git a/packages/application/auth/register_user_use_case.py b/packages/application/auth/register_user_use_case.py old mode 100644 new mode 100755 index a451fca72..ea3f44214 --- a/packages/application/auth/register_user_use_case.py +++ b/packages/application/auth/register_user_use_case.py @@ -52,7 +52,7 @@ class RegisterUserUseCase: def __init__( self, user_repository, - base_url: str = "http://localhost:3000", + base_url: str, email_service=None, ): """ @@ -60,7 +60,7 @@ class RegisterUserUseCase: Args: user_repository: 用户仓储 - base_url: 应用基础 URL(用于生成验证链接) + base_url: 应用基础 URL(用于生成验证链接),必须由调用方传入 """ self.user_repository = user_repository self.base_url = base_url