Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/users/services.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions apps/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
LogoutView,
MeView,
RefreshView,
WithdrawView,
)

urlpatterns = [
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions apps/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
20 changes: 20 additions & 0 deletions infrastructure/storage/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
149 changes: 149 additions & 0 deletions tests/test_withdraw.py
Original file line number Diff line number Diff line change
@@ -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 != "[email protected]"
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 == "[email protected]"


@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="[email protected]",
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
Loading