diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 7fecbe2..ae18cec 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -1,14 +1,12 @@ from fastapi import APIRouter from app.api.v1.endpoints.application import router as application_router -from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.deposit import router as deposit_router from app.api.v1.endpoints.event import router as event_router from app.api.v1.endpoints.s3 import router as s3_router from app.api.v1.endpoints.store import router as store_router router = APIRouter() -router.include_router(auth_router) router.include_router(store_router) router.include_router(event_router) router.include_router(application_router) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py new file mode 100644 index 0000000..90a2a42 --- /dev/null +++ b/app/auth/dependencies.py @@ -0,0 +1,32 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.repository import EmailVerificationRepository, UserRepository +from app.auth.repository_impl import EmailVerificationRepositoryImpl, UserRepositoryImpl +from app.auth.service import AuthService +from app.auth.service_impl import AuthServiceImpl +from app.db.session import get_db +from app.email.service import EmailSender +from app.email.service_impl import SmtpEmailSender + + +def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepository: + return UserRepositoryImpl(db) + + +def get_email_verification_repository( + db: AsyncSession = Depends(get_db), +) -> EmailVerificationRepository: + return EmailVerificationRepositoryImpl(db) + + +def get_email_sender() -> EmailSender: + return SmtpEmailSender() + + +def get_auth_service( + user_repo: UserRepository = Depends(get_user_repository), + ev_repo: EmailVerificationRepository = Depends(get_email_verification_repository), + email_sender: EmailSender = Depends(get_email_sender), +) -> AuthService: + return AuthServiceImpl(user_repo, ev_repo, email_sender) diff --git a/app/auth/models.py b/app/auth/models.py new file mode 100644 index 0000000..5cc724b --- /dev/null +++ b/app/auth/models.py @@ -0,0 +1,41 @@ +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +from sqlalchemy import Boolean, DateTime, Enum, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.base import Base + +if TYPE_CHECKING: + from app.models.deposit import Deposit + + +class User(Base): + __tablename__ = "users" + + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + username: Mapped[str] = mapped_column(String(50)) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String) + role: Mapped[str] = mapped_column(Enum("OWNER", "REVIEWER", name="user_role")) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + deposits: Mapped[list["Deposit"]] = relationship("Deposit", back_populates="user") + + +class EmailVerification(Base): + __tablename__ = "email_verifications" + + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + email: Mapped[str] = mapped_column(String(255), index=True) + code: Mapped[str] = mapped_column(String(6)) + verification_token: Mapped[str | None] = mapped_column(String, nullable=True) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/app/auth/repository.py b/app/auth/repository.py new file mode 100644 index 0000000..b25b296 --- /dev/null +++ b/app/auth/repository.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod + +from app.auth.models import EmailVerification, User + + +class UserRepository(ABC): + @abstractmethod + async def find_by_email(self, email: str) -> User | None: ... + + @abstractmethod + async def find_by_id(self, user_id: str) -> User | None: ... + + @abstractmethod + async def save(self, user: User) -> User: ... + + +class EmailVerificationRepository(ABC): + @abstractmethod + async def save(self, verification: EmailVerification) -> None: ... + + @abstractmethod + async def find_latest_unverified( + self, email: str, code: str + ) -> EmailVerification | None: ... + + @abstractmethod + async def update(self, verification: EmailVerification) -> None: ... diff --git a/app/auth/repository_impl.py b/app/auth/repository_impl.py new file mode 100644 index 0000000..b3136bf --- /dev/null +++ b/app/auth/repository_impl.py @@ -0,0 +1,61 @@ +from datetime import UTC, datetime +from typing import cast +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.models import EmailVerification, User +from app.auth.repository import EmailVerificationRepository, UserRepository + + +class UserRepositoryImpl(UserRepository): + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def find_by_email(self, email: str) -> User | None: + return cast( + User | None, + await self.db.scalar(select(User).where(User.email == email)), + ) + + async def find_by_id(self, user_id: str) -> User | None: + return cast( + User | None, + await self.db.scalar(select(User).where(User.id == UUID(user_id))), + ) + + async def save(self, user: User) -> User: + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + return user + + +class EmailVerificationRepositoryImpl(EmailVerificationRepository): + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def save(self, verification: EmailVerification) -> None: + self.db.add(verification) + await self.db.commit() + + async def find_latest_unverified( + self, email: str, code: str + ) -> EmailVerification | None: + return cast( + EmailVerification | None, + await self.db.scalar( + select(EmailVerification) + .where( + EmailVerification.email == email, + EmailVerification.code == code, + EmailVerification.is_verified.is_(False), + EmailVerification.expires_at > datetime.now(UTC), + ) + .order_by(EmailVerification.created_at.desc()) + ), + ) + + async def update(self, verification: EmailVerification) -> None: + await self.db.commit() diff --git a/app/api/v1/endpoints/auth.py b/app/auth/router.py similarity index 67% rename from app/api/v1/endpoints/auth.py rename to app/auth/router.py index 3a47cb7..a8cd810 100644 --- a/app/api/v1/endpoints/auth.py +++ b/app/auth/router.py @@ -1,10 +1,8 @@ from fastapi import APIRouter, Depends, Request, Response -from sqlalchemy.ext.asyncio import AsyncSession from app.api.v1.deps import require_login -from app.core.exceptions import AUTH_001, AppException -from app.db.session import get_db -from app.schemas.auth import ( +from app.auth.dependencies import get_auth_service +from app.auth.schemas import ( AccessTokenResp, AuthResp, ChangePasswordReq, @@ -17,7 +15,8 @@ ResetPasswordReq, UserInfo, ) -from app.services import auth as auth_service +from app.auth.service import AuthService +from app.core.exceptions import AUTH_001, AppException router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -25,25 +24,25 @@ @router.post("/email", response_model=None) async def check_email( body: EmailCheckReq, - db: AsyncSession = Depends(get_db), + service: AuthService = Depends(get_auth_service), ) -> None: - await auth_service.check_email_duplicate(db, body.email) + await service.check_email_duplicate(body.email) @router.post("/email/verify", response_model=None) async def verify_email( body: EmailVerifyReq, - db: AsyncSession = Depends(get_db), + service: AuthService = Depends(get_auth_service), ) -> None: - await auth_service.send_verification_code(db, body.email) + await service.send_verification_code(body.email) @router.post("/email/validate", response_model=EmailValidateResp) async def validate_email( body: EmailValidateReq, - db: AsyncSession = Depends(get_db), + service: AuthService = Depends(get_auth_service), ) -> EmailValidateResp: - token = await auth_service.validate_verification_code(db, body.email, body.code) + token = await service.validate_verification_code(body.email, body.code) return EmailValidateResp(verificationToken=token) @@ -51,9 +50,9 @@ async def validate_email( async def register( body: RegisterReq, response: Response, - db: AsyncSession = Depends(get_db), + service: AuthService = Depends(get_auth_service), ) -> AuthResp: - user, access_token, refresh_token = await auth_service.register(db, body) + user, access_token, refresh_token = await service.register(body) response.set_cookie( key="refresh_token", value=refresh_token, @@ -67,11 +66,14 @@ async def register( @router.get("/token", response_model=AccessTokenResp) -async def refresh_token(request: Request) -> AccessTokenResp: +async def refresh_token( + request: Request, + service: AuthService = Depends(get_auth_service), +) -> AccessTokenResp: token = request.cookies.get("refresh_token") if not token: raise AppException(AUTH_001) - access_token = auth_service.refresh_access_token(token) + access_token = service.refresh_access_token(token) return AccessTokenResp(accessToken=access_token) @@ -79,9 +81,9 @@ async def refresh_token(request: Request) -> AccessTokenResp: async def login( body: LoginReq, response: Response, - db: AsyncSession = Depends(get_db), + service: AuthService = Depends(get_auth_service), ) -> AuthResp: - user, access_token, refresh_token = await auth_service.login(db, body) + user, access_token, refresh_token = await service.login(body) response.set_cookie( key="refresh_token", value=refresh_token, @@ -105,15 +107,15 @@ async def logout( @router.patch("/password", response_model=None) async def change_password( body: ChangePasswordReq, - db: AsyncSession = Depends(get_db), + service: AuthService = Depends(get_auth_service), user_id: str = Depends(require_login), ) -> None: - await auth_service.change_password(db, user_id, body.oldPassword, body.newPassword) + await service.change_password(user_id, body.oldPassword, body.newPassword) @router.post("/password", response_model=None) async def reset_password( body: ResetPasswordReq, - db: AsyncSession = Depends(get_db), + service: AuthService = Depends(get_auth_service), ) -> None: - await auth_service.reset_password(db, body) + await service.reset_password(body) diff --git a/app/schemas/auth.py b/app/auth/schemas.py similarity index 100% rename from app/schemas/auth.py rename to app/auth/schemas.py diff --git a/app/auth/service.py b/app/auth/service.py new file mode 100644 index 0000000..8e52332 --- /dev/null +++ b/app/auth/service.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod + +from app.auth.models import User +from app.auth.schemas import LoginReq, RegisterReq, ResetPasswordReq + + +class AuthService(ABC): + @abstractmethod + async def check_email_duplicate(self, email: str) -> None: ... + + @abstractmethod + async def send_verification_code(self, email: str) -> None: ... + + @abstractmethod + async def validate_verification_code(self, email: str, code: str) -> str: ... + + @abstractmethod + async def register(self, data: RegisterReq) -> tuple[User, str, str]: ... + + @abstractmethod + def refresh_access_token(self, refresh_token: str) -> str: ... + + @abstractmethod + async def login(self, data: LoginReq) -> tuple[User, str, str]: ... + + @abstractmethod + async def change_password( + self, user_id: str, old_password: str, new_password: str + ) -> None: ... + + @abstractmethod + async def reset_password(self, data: ResetPasswordReq) -> None: ... diff --git a/app/auth/service_impl.py b/app/auth/service_impl.py new file mode 100644 index 0000000..d90d99e --- /dev/null +++ b/app/auth/service_impl.py @@ -0,0 +1,120 @@ +import random +import string +from datetime import UTC, datetime, timedelta + +from app.auth.models import EmailVerification, User +from app.auth.repository import EmailVerificationRepository, UserRepository +from app.auth.schemas import LoginReq, RegisterReq, ResetPasswordReq +from app.auth.service import AuthService +from app.core.exceptions import ( + AUTH_001, + AUTH_002, + USER_001, + USER_002, + USER_006, + AppException, +) +from app.core.security import ( + create_access_token, + create_refresh_token, + create_verification_token, + decode_token, + get_password_hash, + verify_password, +) +from app.email.service import EmailSender + + +class AuthServiceImpl(AuthService): + def __init__( + self, + user_repo: UserRepository, + ev_repo: EmailVerificationRepository, + email_sender: EmailSender, + ) -> None: + self.user_repo = user_repo + self.ev_repo = ev_repo + self.email_sender = email_sender + + async def check_email_duplicate(self, email: str) -> None: + if await self.user_repo.find_by_email(email): + raise AppException(USER_001) + + async def send_verification_code(self, email: str) -> None: + code = "".join(random.choices(string.digits, k=6)) + expires_at = datetime.now(UTC) + timedelta(minutes=10) + verification = EmailVerification(email=email, code=code, expires_at=expires_at) + await self.ev_repo.save(verification) + subject = "[VLSI] 이메일 인증 코드" + body = f""" +
안녕하세요,
+아래 인증 코드를 입력해 주세요. 코드는 10분 후 만료됩니다.
+본인이 요청하지 않은 경우 이 메일을 무시하세요.
+ """ + await self.email_sender.send(email, subject, body) + + async def validate_verification_code(self, email: str, code: str) -> str: + record = await self.ev_repo.find_latest_unverified(email, code) + if not record: + raise AppException(USER_006) + token = create_verification_token(email) + record.is_verified = True + record.verification_token = token + await self.ev_repo.update(record) + return token + + async def register(self, data: RegisterReq) -> tuple[User, str, str]: + if await self.user_repo.find_by_email(data.email): + raise AppException(USER_001) + user = User( + username=data.username, + email=data.email, + password_hash=get_password_hash(data.password), + role=data.role, + ) + user = await self.user_repo.save(user) + user_id = str(user.id) + return user, create_access_token(user_id), create_refresh_token(user_id) + + def refresh_access_token(self, refresh_token: str) -> str: + try: + user_id = decode_token(refresh_token) + except Exception as e: + raise AppException(AUTH_001) from e + return create_access_token(user_id) + + async def login(self, data: LoginReq) -> tuple[User, str, str]: + user = await self.user_repo.find_by_email(data.mail) + if not user or not verify_password(data.password, user.password_hash): + raise AppException(AUTH_002) + if user.role != data.role: + raise AppException(AUTH_002) + user_id = str(user.id) + return user, create_access_token(user_id), create_refresh_token(user_id) + + async def change_password( + self, user_id: str, old_password: str, new_password: str + ) -> None: + user = await self.user_repo.find_by_id(user_id) + if not user or not verify_password(old_password, user.password_hash): + raise AppException(AUTH_002) + user.password_hash = get_password_hash(new_password) + await self.user_repo.save(user) + + async def reset_password(self, data: ResetPasswordReq) -> None: + user = await self.user_repo.find_by_email(data.email) + if not user: + raise AppException(USER_002) + temp_password = "".join( + random.choices(string.ascii_letters + string.digits, k=12) + ) + user.password_hash = get_password_hash(temp_password) + await self.user_repo.save(user) + subject = "[VLSI] 임시 비밀번호 안내" + body = f""" +안녕하세요,
+요청하신 임시 비밀번호입니다. 로그인 후 반드시 비밀번호를 변경해 주세요.
+안녕하세요,
-아래 인증 코드를 입력해 주세요. 코드는 10분 후 만료됩니다.
-본인이 요청하지 않은 경우 이 메일을 무시하세요.
- """ - await send_email(to, subject, body) - - -async def send_temp_password_email(to: str, temp_password: str) -> None: - subject = "[VLSI] 임시 비밀번호 안내" - body = f""" -안녕하세요,
-요청하신 임시 비밀번호입니다. 로그인 후 반드시 비밀번호를 변경해 주세요.
-안녕하세요,
-스마트컨트랙트를 통한 리워드 지급이 완료되었습니다.
-| 지급 이벤트 | -{event_title} | -
| 지급 금액 | -{reward_eth:.6f} ETH | -
| 현재 지갑 잔액 | -{balance_eth:.6f} ETH | -
- 본 메일은 자동 발송된 메일입니다. -
- """ - await send_email(to, subject, body) diff --git a/app/email/__init__.py b/app/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/email/service.py b/app/email/service.py new file mode 100644 index 0000000..2669dd8 --- /dev/null +++ b/app/email/service.py @@ -0,0 +1,6 @@ +from abc import ABC, abstractmethod + + +class EmailSender(ABC): + @abstractmethod + async def send(self, to: str, subject: str, body_html: str) -> None: ... diff --git a/app/email/service_impl.py b/app/email/service_impl.py new file mode 100644 index 0000000..218a340 --- /dev/null +++ b/app/email/service_impl.py @@ -0,0 +1,41 @@ +import asyncio +import logging +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from app.core.config import settings +from app.core.exceptions import MAIL_001, AppException +from app.email.service import EmailSender + +logger = logging.getLogger(__name__) + + +def _send_via_smtp(to: str, subject: str, body_html: str) -> None: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = settings.smtp_user + msg["To"] = to + msg.attach(MIMEText(body_html, "html", "utf-8")) + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server: + server.ehlo() + server.starttls() + server.login(settings.smtp_user, settings.smtp_password) + server.sendmail(settings.smtp_user, to, msg.as_string()) + + +async def send_email(to: str, subject: str, body_html: str) -> None: + if not settings.smtp_user or not settings.smtp_password: + logger.warning("[EMAIL] SMTP credentials not set — skipping send to %s", to) + return + try: + await asyncio.to_thread(_send_via_smtp, to, subject, body_html) + except smtplib.SMTPException as e: + logger.error("[EMAIL] Failed to send email to %s: %s", to, e) + raise AppException(MAIL_001) from e + + +class SmtpEmailSender(EmailSender): + async def send(self, to: str, subject: str, body_html: str) -> None: + await send_email(to, subject, body_html) diff --git a/app/main.py b/app/main.py index eda99dc..bdfd2c8 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse from app.api.v1.router import router as api_router +from app.auth.router import router as auth_router from app.core.config import settings from app.core.exceptions import AppException from app.db.seed import reset_and_seed @@ -22,6 +23,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan) +app.include_router(auth_router, prefix="/api") app.include_router(api_router, prefix="/api") diff --git a/app/models/email_verification.py b/app/models/email_verification.py index 2322d2c..2aae3b4 100644 --- a/app/models/email_verification.py +++ b/app/models/email_verification.py @@ -1,22 +1 @@ -from datetime import datetime -from uuid import UUID, uuid4 - -from sqlalchemy import Boolean, DateTime, String -from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.sql import func - -from app.db.base import Base - - -class EmailVerification(Base): - __tablename__ = "email_verifications" - - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) - email: Mapped[str] = mapped_column(String(255), index=True) - code: Mapped[str] = mapped_column(String(6)) - verification_token: Mapped[str | None] = mapped_column(String, nullable=True) - is_verified: Mapped[bool] = mapped_column(Boolean, default=False) - expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) +from app.auth.models import EmailVerification as EmailVerification # noqa: F401 diff --git a/app/models/user.py b/app/models/user.py index e731191..88f124d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,27 +1 @@ -from datetime import datetime -from typing import TYPE_CHECKING -from uuid import UUID, uuid4 - -from sqlalchemy import DateTime, Enum, String -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.sql import func - -from app.db.base import Base - -if TYPE_CHECKING: - from app.models.deposit import Deposit - - -class User(Base): - __tablename__ = "users" - - id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) - username: Mapped[str] = mapped_column(String(50)) - email: Mapped[str] = mapped_column(String(255), unique=True, index=True) - password_hash: Mapped[str] = mapped_column(String) - role: Mapped[str] = mapped_column(Enum("OWNER", "REVIEWER", name="user_role")) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - deposits: Mapped[list["Deposit"]] = relationship("Deposit", back_populates="user") +from app.auth.models import User as User # noqa: F401 diff --git a/app/services/auth.py b/app/services/auth.py deleted file mode 100644 index fc9bdf4..0000000 --- a/app/services/auth.py +++ /dev/null @@ -1,118 +0,0 @@ -import random -import string -from datetime import UTC, datetime, timedelta -from uuid import UUID - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.email import send_temp_password_email, send_verification_email -from app.core.exceptions import ( - AUTH_001, - AUTH_002, - USER_001, - USER_002, - USER_006, - AppException, -) -from app.core.security import ( - create_access_token, - create_refresh_token, - create_verification_token, - decode_token, - get_password_hash, - verify_password, -) -from app.models.email_verification import EmailVerification -from app.models.user import User -from app.schemas.auth import LoginReq, RegisterReq, ResetPasswordReq - - -async def send_verification_code(db: AsyncSession, email: str) -> None: - code = "".join(random.choices(string.digits, k=6)) - expires_at = datetime.now(UTC) + timedelta(minutes=10) - verification = EmailVerification(email=email, code=code, expires_at=expires_at) - db.add(verification) - await db.commit() - await send_verification_email(email, code) - - -async def validate_verification_code(db: AsyncSession, email: str, code: str) -> str: - record = await db.scalar( - select(EmailVerification) - .where( - EmailVerification.email == email, - EmailVerification.code == code, - EmailVerification.is_verified.is_(False), - EmailVerification.expires_at > datetime.now(UTC), - ) - .order_by(EmailVerification.created_at.desc()) - ) - if not record: - raise AppException(USER_006) - token = create_verification_token(email) - record.is_verified = True - record.verification_token = token - await db.commit() - return token - - -async def register(db: AsyncSession, data: RegisterReq) -> tuple[User, str, str]: - exists = await db.scalar(select(User).where(User.email == data.email)) - if exists: - raise AppException(USER_001) - user = User( - username=data.username, - email=data.email, - password_hash=get_password_hash(data.password), - role=data.role, - ) - db.add(user) - await db.commit() - await db.refresh(user) - user_id = str(user.id) - return user, create_access_token(user_id), create_refresh_token(user_id) - - -def refresh_access_token(refresh_token: str) -> str: - try: - user_id = decode_token(refresh_token) - except Exception as e: - raise AppException(AUTH_001) from e - return create_access_token(user_id) - - -async def login(db: AsyncSession, data: LoginReq) -> tuple[User, str, str]: - user = await db.scalar(select(User).where(User.email == data.mail)) - if not user or not verify_password(data.password, user.password_hash): - raise AppException(AUTH_002) - if user.role != data.role: - raise AppException(AUTH_002) - user_id = str(user.id) - return user, create_access_token(user_id), create_refresh_token(user_id) - - -async def change_password( - db: AsyncSession, user_id: str, old_password: str, new_password: str -) -> None: - user = await db.scalar(select(User).where(User.id == UUID(user_id))) - if not user or not verify_password(old_password, user.password_hash): - raise AppException(AUTH_002) - user.password_hash = get_password_hash(new_password) - await db.commit() - - -async def reset_password(db: AsyncSession, data: ResetPasswordReq) -> None: - user = await db.scalar(select(User).where(User.email == data.email)) - if not user: - raise AppException(USER_002) - temp_password = "".join(random.choices(string.ascii_letters + string.digits, k=12)) - user.password_hash = get_password_hash(temp_password) - await db.commit() - await send_temp_password_email(data.email, temp_password) - - -async def check_email_duplicate(db: AsyncSession, email: str) -> None: - exists = await db.scalar(select(User).where(User.email == email)) - if exists: - raise AppException(USER_001) diff --git a/app/services/blockchain.py b/app/services/blockchain.py index 40c4a60..6e81aea 100644 --- a/app/services/blockchain.py +++ b/app/services/blockchain.py @@ -39,7 +39,7 @@ from web3 import AsyncWeb3 from app.core.config import settings -from app.core.email import send_reward_email +from app.email.service_impl import send_email logger = logging.getLogger(__name__) @@ -168,7 +168,31 @@ async def payout_safe( try: await payout(contract_address, recipient, amount_wei) wallet_balance = await get_wallet_balance(recipient) - await send_reward_email(reviewer_email, event_title, amount_wei, wallet_balance) + reward_eth = amount_wei / 10**18 + balance_eth = wallet_balance / 10**18 + subject = "[VLSI] 리워드 지급 완료" + body = f""" +안녕하세요,
+스마트컨트랙트를 통한 리워드 지급이 완료되었습니다.
+| 지급 이벤트 | +{event_title} | +
| 지급 금액 | +{reward_eth:.6f} ETH | +
| 현재 지갑 잔액 | +{balance_eth:.6f} ETH | +
+ 본 메일은 자동 발송된 메일입니다. +
+ """ + await send_email(reviewer_email, subject, body) except Exception: logger.exception( "[BLOCKCHAIN] payout failed contract=%s recipient=%s amount_wei=%d", diff --git a/tests/conftest.py b/tests/conftest.py index c68927d..4486d5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,9 +13,11 @@ ) from sqlalchemy.pool import StaticPool +from app.auth.dependencies import get_email_sender from app.core.security import create_access_token, get_password_hash from app.db.base import Base from app.db.session import get_db +from app.email.service import EmailSender from app.main import app from app.models.application import Application from app.models.deposit import Deposit @@ -27,9 +29,13 @@ DATABASE_URL = "sqlite+aiosqlite:///:memory:" +class _NoopEmailSender(EmailSender): + async def send(self, to: str, subject: str, body_html: str) -> None: ... + + @pytest.fixture(autouse=True) def mock_send_email() -> Generator[AsyncMock, None, None]: - with patch("app.core.email.send_email", new_callable=AsyncMock) as mock: + with patch("app.email.service_impl.send_email", new_callable=AsyncMock) as mock: yield mock @@ -62,6 +68,7 @@ async def override_get_db() -> AsyncGenerator[AsyncSession, None]: yield session app.dependency_overrides[get_db] = override_get_db + app.dependency_overrides[get_email_sender] = _NoopEmailSender async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: