refactor(auth): configure external service adapters

This commit is contained in:
Xiaoxia AI
2026-06-21 11:04:07 +08:00
parent b78a290bdd
commit 3594cc1216
10 changed files with 129 additions and 23 deletions
+13 -13
View File
@@ -7,10 +7,12 @@ app.dependencies and authentication behavior lives in application use cases.
from app.auth import AuthenticatedUser, get_current_user
from app.config import settings
from app.dependencies import get_user_repository
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from packages.adapters.redis import NoopSessionStore
from packages.adapters.smtp import NoopEmailService
from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest
from packages.application.auth.login_use_case import LoginUseCase
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
@@ -85,11 +87,12 @@ class MessageResponse(BaseModel):
async def register(
request: RegisterRequest,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
):
use_case = RegisterUserUseCase(
user_repository=user_repository,
base_url="http://localhost:3000",
email_service=_NoopEmailService(),
email_service=email_service,
)
response, error = use_case.execute(
RegisterUseCaseRequest(
@@ -115,10 +118,11 @@ async def register(
async def login(
request: LoginRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
):
use_case = LoginUseCase(
user_repository=user_repository,
session_store=_NoopSessionStore(),
session_store=session_store,
jwt_secret_key=settings.JWT_SECRET_KEY,
)
response, error = use_case.execute(LoginUseCaseRequest(email=request.email, password=request.password))
@@ -164,11 +168,12 @@ async def verify_email_post(
async def forgot_password(
request: PasswordResetRequestModel,
user_repository: UserRepository = Depends(get_user_repository),
email_service=Depends(get_auth_email_service),
):
success, error = RequestPasswordResetUseCase(
user_repository=user_repository,
base_url="http://localhost:3000",
email_service=_NoopEmailService(),
email_service=email_service,
).execute(PasswordResetUseCaseRequest(email=request.email))
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "密码重置请求失败")
@@ -205,17 +210,12 @@ async def get_current_user_info(
)
class _NoopSessionStore:
def save_session(self, **kwargs):
return None
class _NoopSessionStore(NoopSessionStore):
pass
class _NoopEmailService:
def send_verification_email(self, **kwargs):
return False, "Email delivery is disabled for compatibility auth routes"
def send_password_reset_email(self, **kwargs):
return False, "Email delivery is disabled for compatibility auth routes"
class _NoopEmailService(NoopEmailService):
pass
def _translate_auth_error(error: str | None) -> str:
+10
View File
@@ -24,9 +24,19 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_MAX_CONNECTIONS: int = 50
ENABLE_REDIS_SESSIONS: bool = False
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
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
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
+26
View File
@@ -1,7 +1,10 @@
import redis
from app.config import settings
from fastapi import Depends
from sqlalchemy.orm import Session
from packages.adapters.redis import NoopSessionStore, SessionStore
from packages.adapters.smtp import EmailConfig, NoopEmailService, get_email_service
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository,
)
@@ -106,3 +109,26 @@ def get_workspace_invitation_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceInvitationRepository:
return SQLAlchemyWorkspaceInvitationRepository(session)
def get_auth_session_store():
if not settings.ENABLE_REDIS_SESSIONS:
return NoopSessionStore()
return SessionStore(redis_client=redis.from_url(settings.REDIS_URL, decode_responses=True))
def get_auth_email_service():
if not settings.ENABLE_EMAIL_DELIVERY:
return NoopEmailService()
return get_email_service(
EmailConfig(
smtp_host=settings.SMTP_HOST,
smtp_port=settings.SMTP_PORT,
smtp_user=settings.SMTP_USER,
smtp_password=settings.SMTP_PASSWORD,
from_email=settings.SMTP_FROM_EMAIL,
from_name=settings.SMTP_FROM_NAME,
use_tls=settings.SMTP_USE_TLS,
),
enabled=True,
)
+14 -1
View File
@@ -61,7 +61,20 @@ infra/docker/deploy-staging.sh
- 已存在业务表但缺少 `alembic_version` 时执行 `alembic stamp head && alembic upgrade head`
- 空库时执行 `alembic upgrade head`
staging 已在 2026-06-21 验证:`alembic_version = 001`,健康检查通过。
staging 已在 2026-06-21 验证:`alembic_version = 002`,健康检查通过。
---
## 外部服务配置
认证邮件和 session 存储为配置驱动:
- `ENABLE_EMAIL_DELIVERY=false` 时使用 no-op 邮件服务,不对外发送邮件。
- `ENABLE_EMAIL_DELIVERY=true` 时必须配置 `SMTP_HOST``SMTP_PORT``SMTP_USER``SMTP_PASSWORD``SMTP_FROM_EMAIL``SMTP_FROM_NAME``SMTP_USE_TLS`
- `ENABLE_REDIS_SESSIONS=false` 时登录不会写 Redis session,JWT 仍可用于当前认证链路。
- `ENABLE_REDIS_SESSIONS=true` 时通过 `REDIS_URL` 创建 Redis session store。
Staging 当前可以保持 no-opProduction 开启前必须先验证 SMTP/Redis 连接和密钥。
---
+13 -2
View File
@@ -314,6 +314,17 @@ python -m pytest tests/unit/test_login_use_case.py tests/unit/test_register_user
- 邮件发送在当前 compatibility route 中仍使用 no-op email service,接入真实 SMTP adapter 前不会对外发信。
- 已修复 password reset token 过期判断中的 naive/aware datetime 比较风险。
### P1:外部服务 adapter 化收尾(已配置驱动)
已完成:
- 认证 route 不再持有私有 no-op email/session 类,改为从 `apps/api/app/dependencies.py` 获取配置驱动 adapter。
- `ENABLE_EMAIL_DELIVERY=false` 时使用 no-op email service`true` 时按 SMTP 配置创建 `EmailService`
- `ENABLE_REDIS_SESSIONS=false` 时使用 no-op session store`true` 时按 `REDIS_URL` 创建 Redis session store。
- SMTP/Redis no-op 实现放在 adapter 层,use case 仍通过构造注入使用。
剩余建议:
- Production 开启前需要真实 SMTP/Redis 连接验证和密钥审计。
### P1:Domain 层存在外部基础设施依赖
涉及:
@@ -353,7 +364,7 @@ python -m pytest tests/unit/test_login_use_case.py tests/unit/test_register_user
## 五、下一步建议修复顺序
1. 外部服务 adapter 化收尾:接入真实 Redis session / SMTP email 生产配置
2. 生产 Alembic 准备:备份、回滚、首次生产 stamp/upgrade 演练
1. 生产 Alembic 准备:备份、回滚、首次生产 stamp/upgrade 演练
2. Production 外部服务上线:验证 SMTP/Redis 连接、密钥和告警
3. 继续清理旧术语:MinIO/OSS 混合文档、Postgres psycopg 历史文档。
4. 全量测试和 CI:后端 unit/integration + 前端 type-check/build + staging smoke。
+2 -1
View File
@@ -1,7 +1,8 @@
from packages.adapters.redis.session_store import (
NoopSessionStore,
RedisConfig,
SessionStore,
get_session_store,
)
__all__ = ["RedisConfig", "SessionStore", "get_session_store"]
__all__ = ["NoopSessionStore", "RedisConfig", "SessionStore", "get_session_store"]
+29 -1
View File
@@ -21,6 +21,32 @@ class RedisConfig:
DECODE_RESPONSES: bool = True
class NoopSessionStore:
def save_session(self, **kwargs) -> bool:
return False
def get_session(self, session_id: str) -> Optional[dict]:
return None
def get_refresh_token(self, session_id: str) -> Optional[str]:
return None
def update_last_active(self, session_id: str) -> bool:
return False
def delete_session(self, session_id: str) -> bool:
return False
def get_user_sessions(self, user_id: str) -> list[dict]:
return []
def delete_all_user_sessions(self, user_id: str) -> int:
return 0
def session_exists(self, session_id: str) -> bool:
return False
class SessionStore:
"""Session 存储服务"""
@@ -284,8 +310,10 @@ class SessionStore:
_session_store = None
def get_session_store() -> SessionStore:
def get_session_store(enabled: bool = True):
global _session_store
if not enabled:
return NoopSessionStore()
if _session_store is None:
_session_store = SessionStore()
return _session_store
+2 -1
View File
@@ -1,7 +1,8 @@
from packages.adapters.smtp.email_service import (
EmailConfig,
EmailService,
NoopEmailService,
get_email_service,
)
__all__ = ["EmailConfig", "EmailService", "get_email_service"]
__all__ = ["EmailConfig", "EmailService", "NoopEmailService", "get_email_service"]
+16 -3
View File
@@ -10,6 +10,17 @@ from email.mime.text import MIMEText
from typing import List, Optional
class NoopEmailService:
def send_verification_email(self, **kwargs):
return False, "Email delivery is disabled"
def send_password_reset_email(self, **kwargs):
return False, "Email delivery is disabled"
def send_workspace_invitation_email(self, **kwargs):
return False, "Email delivery is disabled"
@dataclass
class EmailConfig:
"""邮件配置"""
@@ -326,8 +337,10 @@ class EmailService:
_email_service = None
def get_email_service() -> EmailService:
def get_email_service(config: Optional[EmailConfig] = None, enabled: bool = True):
global _email_service
if _email_service is None:
_email_service = EmailService()
if not enabled:
return NoopEmailService()
if _email_service is None or config is not None:
_email_service = EmailService(config)
return _email_service
+4 -1
View File
@@ -23,6 +23,7 @@ from app.api.routes.auth import (
from app.auth import get_current_user
from app.config import settings
from packages.adapters.smtp import NoopEmailService
from packages.application.auth.login_use_case import LoginRequest, LoginUseCase
from packages.application.auth.register_user_use_case import RegisterUserRequest, RegisterUserUseCase
from packages.domain.auth import password_hasher
@@ -224,7 +225,9 @@ def test_password_reset_routes_use_use_cases():
)
repo.save(user)
forgot_response = asyncio.run(forgot_password(PasswordResetRequestModel(email="user@example.com"), repo))
forgot_response = asyncio.run(
forgot_password(PasswordResetRequestModel(email="user@example.com"), repo, NoopEmailService())
)
assert forgot_response.message == "如果账户存在,密码重置邮件已发送"
assert user.password_reset_token