diff --git a/apps/users/services.py b/apps/users/services.py new file mode 100644 index 0000000..5d4cbfd --- /dev/null +++ b/apps/users/services.py @@ -0,0 +1,70 @@ +"""회원 관련 도메인 서비스.""" + +from __future__ import annotations + +from datetime import date + +from django.db import transaction + +from apps.characters.models import Character, SourceImage +from apps.users.models import SocialAccount, User +from infrastructure.storage.s3 import delete_object + +# birth 는 not-null 이라 익명화 시 실제 생일 대신 쓰는 센티넬 값. +_WITHDRAWN_BIRTH = date(1900, 1, 1) + + +def _collect_original_image_keys(user: User) -> list[str]: + """유저의 원본 사진 S3 object key 모음(SourceImage + Character.origin_img_url).""" + keys = list( + SourceImage.objects.filter(user=user) + .exclude(object_key="") + .values_list("object_key", flat=True) + ) + keys += list( + Character.objects.filter(user=user) + .exclude(origin_img_url="") + .values_list("origin_img_url", flat=True) + ) + return keys + + +def withdraw_user(user: User) -> None: + """회원 탈퇴 처리. + + 개인정보(원본이미지·닉네임·이메일·비밀번호·생년월일·직업)를 삭제/익명화하고 + 계정을 비활성화한다. 이용 로그(퀘스트·피드·토큰·생성 횟수 등)는 유지하기 위해 + User 행 자체는 지우지 않는다. 원본 사진은 S3 에서 best-effort 로 삭제한다. + """ + image_keys = _collect_original_image_keys(user) + + with transaction.atomic(): + # 원본 업로드 기록 제거(생성 잡의 FK 는 SET_NULL 이라 잡 로그는 유지) + SourceImage.objects.filter(user=user).delete() + # 캐릭터 행은 로그(퀘스트·피드)가 참조하므로 유지하되 원본 사진 키만 비운다 + Character.objects.filter(user=user).update(origin_img_url="") + # 외부 신원 링크 제거 + SocialAccount.objects.filter(user=user).delete() + + # 개인정보 익명화 + 비활성화 (User 행은 로그 보존 위해 삭제하지 않음) + user.email = f"withdrawn_{user.user_id}@deleted.invalid" + user.user_name = "(탈퇴)" + user.job = "" + user.birth = _WITHDRAWN_BIRTH + user.set_unusable_password() + user.is_active = False + user.save( + update_fields=[ + "email", + "user_name", + "job", + "birth", + "password", + "is_active", + "updated_at", + ] + ) + + # S3 삭제는 트랜잭션 밖에서 best-effort (실패해도 DB 익명화는 유지) + for key in image_keys: + delete_object(key) diff --git a/apps/users/urls.py b/apps/users/urls.py index a3525de..4e125fe 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -7,6 +7,7 @@ LogoutView, MeView, RefreshView, + WithdrawView, ) urlpatterns = [ @@ -15,6 +16,7 @@ path("token/refresh", RefreshView.as_view(), name="auth-token-refresh"), path("logout", LogoutView.as_view(), name="auth-logout"), path("change-password/", ChangePasswordView.as_view(), name="auth-change-password"), + path("withdraw", WithdrawView.as_view(), name="auth-withdraw"), path("social/kakao", KakaoLoginView.as_view(), name="auth-social-kakao"), path( "social/kakao/complete", diff --git a/apps/users/views.py b/apps/users/views.py index beb1660..e7ac40c 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -24,6 +24,7 @@ validate_refresh_token, ) from apps.users.serializers import UserSerializer, UserUpdateSerializer +from apps.users.services import withdraw_user from apps.users.validators import ( collect_validated_fields, validate_email, @@ -239,3 +240,28 @@ def post(self, request: Request) -> HttpResponseBase: ) set_refresh_cookie(response, new_raw, persistent=persistent) return response + + +class WithdrawView(APIView): + permission_classes = (IsAuthenticated,) + + def post(self, request: Request) -> HttpResponseBase: + # 이메일 가입자는 현재 비밀번호 확인, 소셜 가입자는 비번 없이 진행한다. + if request.user.login_type == User.LoginType.EMAIL: # type: ignore[union-attr] + if not isinstance(request.data, dict): + return error_response(400, "VALIDATION_ERROR") + password = request.data.get("password") + if not isinstance(password, str) or not password: + return validation_error_response( + ValidationError({"password": ["현재 비밀번호를 입력해 주세요."]}) + ) + if not request.user.check_password(password): # type: ignore[union-attr] + return error_response(400, "INVALID_CURRENT_PASSWORD") + + # 전 기기의 자동로그인 토큰을 무효화하고 개인정보를 익명화·비활성화한다. + revoke_all_user_tokens(request.user) + withdraw_user(request.user) # type: ignore[arg-type] + + response = Response(status=status.HTTP_204_NO_CONTENT) + clear_refresh_cookie(response) + return response diff --git a/infrastructure/storage/s3.py b/infrastructure/storage/s3.py index cb58951..2c1ba01 100644 --- a/infrastructure/storage/s3.py +++ b/infrastructure/storage/s3.py @@ -135,3 +135,23 @@ def check_object_exists(object_key: str) -> bool: return False else: return True + + +def delete_object(object_key: str) -> bool: + """S3 오브젝트를 삭제한다(best-effort). 성공이면 True, 실패면 False. + + 회원 탈퇴 시 원본 사진처럼 개인정보 객체를 제거하는 데 쓴다. + object_key 가 비어 있으면 아무 것도 하지 않고 True 를 반환한다. + """ + if not object_key: + return True + # 설정 미비(StorageNotConfiguredError)·클라이언트 생성 실패까지 모두 흡수해 + # 탈퇴 흐름이 S3 문제로 깨지지 않게 한다(best-effort). + try: + _ensure_storage_configured() + client = get_s3_client() + client.delete_object(Bucket=settings.AWS_S3_BUCKET, Key=object_key) + except Exception: + return False + else: + return True diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py new file mode 100644 index 0000000..a6bba0d --- /dev/null +++ b/tests/test_withdraw.py @@ -0,0 +1,149 @@ +"""회원 탈퇴(POST /auth/withdraw) 동작을 검증합니다.""" + +from __future__ import annotations + +from datetime import date +from unittest.mock import patch + +from django.test import override_settings +from django.utils import timezone +import pytest +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from apps.characters.models import Character, ImgGenLog, SourceImage +from apps.users.models import SocialAccount, User + +WITHDRAW_URL = "/api/v1/auth/withdraw" + + +def _source_image(user: User, object_key: str) -> SourceImage: + return SourceImage.objects.create( + user=user, + object_key=object_key, + content_type="image/png", + status=SourceImage.Status.UPLOAD_COMPLETED, + expires_at=timezone.now(), + ) + + +@pytest.mark.django_db +def test_withdraw_email_user_success(auth_client: APIClient, user: User) -> None: + src = _source_image(user, "orig-1.png") + char = Character.objects.create( + user=user, + character_name="몽이", + persona="p", + origin_img_url="char-orig-1.png", + gen_img_url="gen-1.png", + ) + log = ImgGenLog.objects.create(user=user, gen_cnt=1) + + with patch("apps.users.services.delete_object") as mock_delete: + response = auth_client.post( + WITHDRAW_URL, {"password": "password123"}, format="json" + ) + + assert response.status_code == 204 + + user.refresh_from_db() + assert user.is_active is False + assert user.email != "test@test.com" + assert user.user_name == "(탈퇴)" + assert user.job == "" + assert user.birth == date(1900, 1, 1) + assert user.check_password("password123") is False + + # 원본 이미지: SourceImage 삭제 + Character.origin_img_url 비움(행·생성이미지 유지) + assert not SourceImage.objects.filter(pk=src.pk).exists() + char.refresh_from_db() + assert char.origin_img_url == "" + assert char.gen_img_url == "gen-1.png" + + # 이용 로그는 보존된다. + assert ImgGenLog.objects.filter(pk=log.pk).exists() + + # 원본 사진 S3 키들이 삭제 호출된다. + deleted_keys = {call.args[0] for call in mock_delete.call_args_list} + assert deleted_keys == {"orig-1.png", "char-orig-1.png"} + + +@pytest.mark.django_db +def test_withdraw_email_user_wrong_password(auth_client: APIClient, user: User) -> None: + response = auth_client.post(WITHDRAW_URL, {"password": "wrong"}, format="json") + + assert response.status_code == 400 + user.refresh_from_db() + assert user.is_active is True + assert user.email == "test@test.com" + + +@pytest.mark.django_db +def test_withdraw_email_user_requires_password( + auth_client: APIClient, user: User +) -> None: + response = auth_client.post(WITHDRAW_URL, {}, format="json") + + assert response.status_code == 400 + user.refresh_from_db() + assert user.is_active is True + + +@pytest.mark.django_db +def test_withdraw_social_user_without_password() -> None: + kakao_user = User.objects.create_user( + email="kakao@test.com", + user_name="카카오", + birth="1999-01-01", + login_type=User.LoginType.KAKAO, + ) + SocialAccount.objects.create( + user=kakao_user, provider="kakao", provider_id="kakao-1" + ) + client = APIClient() + client.credentials( + HTTP_AUTHORIZATION=f"Bearer {RefreshToken.for_user(kakao_user).access_token}" + ) + + with patch("apps.users.services.delete_object"): + response = client.post(WITHDRAW_URL, {}, format="json") + + assert response.status_code == 204 + kakao_user.refresh_from_db() + assert kakao_user.is_active is False + assert not SocialAccount.objects.filter(user=kakao_user).exists() + + +@pytest.mark.django_db +def test_withdraw_blocks_authentication_afterward( + auth_client: APIClient, user: User +) -> None: + with patch("apps.users.services.delete_object"): + response = auth_client.post( + WITHDRAW_URL, {"password": "password123"}, format="json" + ) + assert response.status_code == 204 + + # is_active=False 라 같은 토큰으로 더 이상 인증되지 않는다. + assert auth_client.get("/api/v1/auth/me/").status_code == 401 + + +@override_settings(AWS_S3_BUCKET="") +@pytest.mark.django_db +def test_withdraw_succeeds_when_s3_unconfigured_with_images( + auth_client: APIClient, user: User +) -> None: + # 원본 이미지가 있어도 S3 미설정 환경에서 탈퇴가 500 없이 완료돼야 한다. + # (delete_object 를 mock 하지 않고 실제 best-effort 경로를 검증한다.) + _source_image(user, "orig-x.png") + Character.objects.create( + user=user, character_name="x", persona="p", origin_img_url="char-x.png" + ) + + response = auth_client.post( + WITHDRAW_URL, {"password": "password123"}, format="json" + ) + + assert response.status_code == 204 + user.refresh_from_db() + assert user.is_active is False