From 24e76776096cecbb5ff74629c62eba951a770bbc Mon Sep 17 00:00:00 2001 From: JeongSW123 <123189509+JeongSW123@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:22:19 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(storage):=20S3=20=EC=98=A4=EB=B8=8C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=ED=97=AC=ED=8D=BC=20?= =?UTF-8?q?delete=5Fobject=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원 탈퇴 시 원본 사진 등 개인정보 객체를 제거하기 위한 best-effort 삭제 헬퍼. 빈 key 는 no-op, 실패 시 False 반환. --- infrastructure/storage/s3.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/infrastructure/storage/s3.py b/infrastructure/storage/s3.py index cb58951..df3af9f 100644 --- a/infrastructure/storage/s3.py +++ b/infrastructure/storage/s3.py @@ -135,3 +135,21 @@ 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 + _ensure_storage_configured() + client = get_s3_client() + try: + client.delete_object(Bucket=settings.AWS_S3_BUCKET, Key=object_key) + except Exception: + return False + else: + return True From a1bc352ab2e7bb127da78787ca38e27f42da0e79 Mon Sep 17 00:00:00 2001 From: JeongSW123 <123189509+JeongSW123@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:31:50 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(users):=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=9D=B5=EB=AA=85=ED=99=94=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20withdraw=5Fuser=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개인정보(원본이미지·닉네임·이메일·비밀번호·생년월일·직업)를 삭제/익명화하고 is_active=False 로 비활성화한다. 이용 로그(퀘스트·피드·토큰·생성 횟수)는 보존하기 위해 User 행은 삭제하지 않는다. - 원본 사진: SourceImage 행 삭제 + Character.origin_img_url 비움 + S3 best-effort 삭제 - 이메일은 충돌 방지 센티넬, 비밀번호는 set_unusable_password - birth 는 not-null 이라 센티넬 날짜(1900-01-01), job 은 빈값 - SocialAccount(외부 신원 링크) 삭제 --- apps/users/services.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/users/services.py 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) From 38b5c298360e2b183b1fc3f2e87d7f81a663df94 Mon Sep 17 00:00:00 2001 From: JeongSW123 <123189509+JeongSW123@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:34:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(users):=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20POST=20/auth/withdraw=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이메일 가입자는 현재 비밀번호 일치 시, 소셜 가입자는 비번 없이 탈퇴한다. 전 기기 자동로그인 토큰을 무효화하고 withdraw_user 로 개인정보를 익명화한 뒤 리프레시 쿠키를 비우고 204 를 반환한다. --- apps/users/urls.py | 2 ++ apps/users/views.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) 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 From ef1ec7146d0a5780a37664f5af52a005439d2d7b Mon Sep 17 00:00:00 2001 From: JeongSW123 <123189509+JeongSW123@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:36:57 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test(users):=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EB=8F=99=EC=9E=91=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이메일 가입자 비번 일치/불일치/누락, 소셜 가입자 비번 없이 탈퇴, 탈퇴 후 인증 차단을 검증한다. 원본이미지 삭제·PII 익명화·로그 보존도 확인. --- tests/test_withdraw.py | 127 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_withdraw.py diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py new file mode 100644 index 0000000..f930c61 --- /dev/null +++ b/tests/test_withdraw.py @@ -0,0 +1,127 @@ +"""회원 탈퇴(POST /auth/withdraw) 동작을 검증합니다.""" + +from __future__ import annotations + +from datetime import date +from unittest.mock import patch + +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 From 5f49d8bbf2b0adbb4d46bb6ffa318451ad0da8c3 Mon Sep 17 00:00:00 2001 From: JeongSW123 <123189509+JeongSW123@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:28:13 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(storage):=20delete=5Fobject=20=EB=A5=BC?= =?UTF-8?q?=20=EC=99=84=EC=A0=84=20best-effort=20=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20S3=20=EB=AF=B8=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20500=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/storage/s3.py | 6 ++++-- tests/test_withdraw.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/infrastructure/storage/s3.py b/infrastructure/storage/s3.py index df3af9f..2c1ba01 100644 --- a/infrastructure/storage/s3.py +++ b/infrastructure/storage/s3.py @@ -145,9 +145,11 @@ def delete_object(object_key: str) -> bool: """ if not object_key: return True - _ensure_storage_configured() - client = get_s3_client() + # 설정 미비(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 diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index f930c61..a6bba0d 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -5,6 +5,7 @@ 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 @@ -125,3 +126,24 @@ def test_withdraw_blocks_authentication_afterward( # 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