From 1d531c68be741202653b74df8cb4795df7240da3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 22:01:19 +0000 Subject: [PATCH 1/2] feat: add delete user account endpoint - Add DELETE /api/user REST endpoint for authenticated user deletion - Add deleteUser GraphQL mutation - Implement cascading deletion of all user-related data: - Comments authored by the user - Article favorites by the user - Favorites on user's articles - Article-tag relations for user's articles - Articles authored by the user - Follow relations (both directions) - User record - Add UserRepository.remove() with @Transactional support - Add UserService.removeUser() method - Add API tests for delete endpoint (204 on success, 401 without auth) - Add integration test for cascading data cleanup Co-Authored-By: Yubin Jee --- .../java/io/spring/api/CurrentUserApi.java | 7 +++ .../spring/application/user/UserService.java | 4 ++ .../io/spring/core/user/UserRepository.java | 2 + .../java/io/spring/graphql/UserMutation.java | 13 ++++++ .../mybatis/mapper/ArticleFavoriteMapper.java | 4 ++ .../mybatis/mapper/ArticleMapper.java | 4 ++ .../mybatis/mapper/CommentMapper.java | 2 + .../mybatis/mapper/UserMapper.java | 6 +++ .../repository/MyBatisUserRepository.java | 30 ++++++++++++- .../mapper/ArticleFavoriteMapper.xml | 6 +++ src/main/resources/mapper/ArticleMapper.xml | 6 +++ src/main/resources/mapper/CommentMapper.xml | 3 ++ src/main/resources/mapper/UserMapper.xml | 9 ++++ src/main/resources/schema/schema.graphqls | 1 + .../io/spring/api/CurrentUserApiTest.java | 16 +++++++ .../user/MyBatisUserRepositoryTest.java | 45 ++++++++++++++++++- 16 files changed, 156 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/spring/api/CurrentUserApi.java b/src/main/java/io/spring/api/CurrentUserApi.java index e096aec0b..6dbf0f651 100644 --- a/src/main/java/io/spring/api/CurrentUserApi.java +++ b/src/main/java/io/spring/api/CurrentUserApi.java @@ -13,6 +13,7 @@ import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -48,6 +49,12 @@ public ResponseEntity updateProfile( return ResponseEntity.ok(userResponse(new UserWithToken(userData, token.split(" ")[1]))); } + @DeleteMapping + public ResponseEntity deleteUser(@AuthenticationPrincipal User currentUser) { + userService.removeUser(currentUser); + return ResponseEntity.noContent().build(); + } + private Map userResponse(UserWithToken userWithToken) { return new HashMap() { { diff --git a/src/main/java/io/spring/application/user/UserService.java b/src/main/java/io/spring/application/user/UserService.java index 48c6735b8..16180ea58 100644 --- a/src/main/java/io/spring/application/user/UserService.java +++ b/src/main/java/io/spring/application/user/UserService.java @@ -54,6 +54,10 @@ public void updateUser(@Valid UpdateUserCommand command) { updateUserParam.getImage()); userRepository.save(user); } + + public void removeUser(User user) { + userRepository.remove(user); + } } @Constraint(validatedBy = UpdateUserValidator.class) diff --git a/src/main/java/io/spring/core/user/UserRepository.java b/src/main/java/io/spring/core/user/UserRepository.java index f52c7725d..286b801b8 100644 --- a/src/main/java/io/spring/core/user/UserRepository.java +++ b/src/main/java/io/spring/core/user/UserRepository.java @@ -18,4 +18,6 @@ public interface UserRepository { Optional findRelation(String userId, String targetId); void removeRelation(FollowRelation followRelation); + + void remove(User user); } diff --git a/src/main/java/io/spring/graphql/UserMutation.java b/src/main/java/io/spring/graphql/UserMutation.java index 581a5b7b5..344206d7f 100644 --- a/src/main/java/io/spring/graphql/UserMutation.java +++ b/src/main/java/io/spring/graphql/UserMutation.java @@ -14,6 +14,7 @@ import io.spring.graphql.DgsConstants.MUTATION; import io.spring.graphql.exception.GraphQLCustomizeExceptionHandler; import io.spring.graphql.types.CreateUserInput; +import io.spring.graphql.types.DeletionStatus; import io.spring.graphql.types.UpdateUserInput; import io.spring.graphql.types.UserPayload; import io.spring.graphql.types.UserResult; @@ -90,4 +91,16 @@ public DataFetcherResult updateUser( .localContext(currentUser) .build(); } + + @DgsData(parentType = MUTATION.TYPE_NAME, field = MUTATION.DeleteUser) + public DeletionStatus deleteUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof AnonymousAuthenticationToken + || authentication.getPrincipal() == null) { + return DeletionStatus.newBuilder().success(false).build(); + } + io.spring.core.user.User currentUser = (io.spring.core.user.User) authentication.getPrincipal(); + userService.removeUser(currentUser); + return DeletionStatus.newBuilder().success(true).build(); + } } diff --git a/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleFavoriteMapper.java b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleFavoriteMapper.java index 2d4407218..a4c61fb5d 100644 --- a/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleFavoriteMapper.java +++ b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleFavoriteMapper.java @@ -11,4 +11,8 @@ public interface ArticleFavoriteMapper { void insert(@Param("articleFavorite") ArticleFavorite articleFavorite); void delete(@Param("favorite") ArticleFavorite favorite); + + void deleteByUserId(@Param("userId") String userId); + + void deleteByArticleUserId(@Param("userId") String userId); } diff --git a/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleMapper.java b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleMapper.java index 2facc3b14..5c3241a8d 100644 --- a/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleMapper.java +++ b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleMapper.java @@ -22,4 +22,8 @@ public interface ArticleMapper { void update(@Param("article") Article article); void delete(@Param("id") String id); + + void deleteByUserId(@Param("userId") String userId); + + void deleteArticleTagsByUserId(@Param("userId") String userId); } diff --git a/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java b/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java index 7137a25ad..2ac1dca76 100644 --- a/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java +++ b/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java @@ -11,4 +11,6 @@ public interface CommentMapper { Comment findById(@Param("articleId") String articleId, @Param("id") String id); void delete(@Param("id") String id); + + void deleteByUserId(@Param("userId") String userId); } diff --git a/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java b/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java index 54f36c76a..e006d17fc 100644 --- a/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java +++ b/src/main/java/io/spring/infrastructure/mybatis/mapper/UserMapper.java @@ -22,4 +22,10 @@ public interface UserMapper { void saveRelation(@Param("followRelation") FollowRelation followRelation); void deleteRelation(@Param("followRelation") FollowRelation followRelation); + + void delete(@Param("id") String id); + + void deleteFollowsByUserId(@Param("userId") String userId); + + void deleteFollowsTargetingUser(@Param("userId") String userId); } diff --git a/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java b/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java index 3c24dd5f0..315bb37ac 100644 --- a/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java +++ b/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java @@ -3,18 +3,32 @@ import io.spring.core.user.FollowRelation; import io.spring.core.user.User; import io.spring.core.user.UserRepository; +import io.spring.infrastructure.mybatis.mapper.ArticleFavoriteMapper; +import io.spring.infrastructure.mybatis.mapper.ArticleMapper; +import io.spring.infrastructure.mybatis.mapper.CommentMapper; import io.spring.infrastructure.mybatis.mapper.UserMapper; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository public class MyBatisUserRepository implements UserRepository { private final UserMapper userMapper; + private final ArticleMapper articleMapper; + private final CommentMapper commentMapper; + private final ArticleFavoriteMapper articleFavoriteMapper; @Autowired - public MyBatisUserRepository(UserMapper userMapper) { + public MyBatisUserRepository( + UserMapper userMapper, + ArticleMapper articleMapper, + CommentMapper commentMapper, + ArticleFavoriteMapper articleFavoriteMapper) { this.userMapper = userMapper; + this.articleMapper = articleMapper; + this.commentMapper = commentMapper; + this.articleFavoriteMapper = articleFavoriteMapper; } @Override @@ -57,4 +71,18 @@ public Optional findRelation(String userId, String targetId) { public void removeRelation(FollowRelation followRelation) { userMapper.deleteRelation(followRelation); } + + @Override + @Transactional + public void remove(User user) { + String userId = user.getId(); + commentMapper.deleteByUserId(userId); + articleFavoriteMapper.deleteByUserId(userId); + articleFavoriteMapper.deleteByArticleUserId(userId); + articleMapper.deleteArticleTagsByUserId(userId); + articleMapper.deleteByUserId(userId); + userMapper.deleteFollowsByUserId(userId); + userMapper.deleteFollowsTargetingUser(userId); + userMapper.delete(userId); + } } diff --git a/src/main/resources/mapper/ArticleFavoriteMapper.xml b/src/main/resources/mapper/ArticleFavoriteMapper.xml index 9fd4dac1c..734cf12c6 100644 --- a/src/main/resources/mapper/ArticleFavoriteMapper.xml +++ b/src/main/resources/mapper/ArticleFavoriteMapper.xml @@ -7,6 +7,12 @@ delete from article_favorites where article_id = #{favorite.articleId} and user_id = #{favorite.userId} + + delete from article_favorites where user_id = #{userId} + + + delete from article_favorites where article_id in (select id from articles where user_id = #{userId}) + select id commentId, diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml index 08e89b224..152837e0a 100644 --- a/src/main/resources/mapper/UserMapper.xml +++ b/src/main/resources/mapper/UserMapper.xml @@ -28,6 +28,15 @@ delete from follows where user_id = #{followRelation.userId} and follow_id = #{followRelation.targetId} + + delete from users where id = #{id} + + + delete from follows where user_id = #{userId} + + + delete from follows where follow_id = #{userId} + diff --git a/src/main/resources/schema/schema.graphqls b/src/main/resources/schema/schema.graphqls index a3f6be557..de0ac1b34 100644 --- a/src/main/resources/schema/schema.graphqls +++ b/src/main/resources/schema/schema.graphqls @@ -23,6 +23,7 @@ type Mutation { createUser(input: CreateUserInput): UserResult login(password: String!, email: String!): UserPayload updateUser(changes: UpdateUserInput!): UserPayload + deleteUser: DeletionStatus followUser(username: String!): ProfilePayload unfollowUser(username: String!): ProfilePayload diff --git a/src/test/java/io/spring/api/CurrentUserApiTest.java b/src/test/java/io/spring/api/CurrentUserApiTest.java index 08e8ece2e..d5374f0f4 100644 --- a/src/test/java/io/spring/api/CurrentUserApiTest.java +++ b/src/test/java/io/spring/api/CurrentUserApiTest.java @@ -161,6 +161,22 @@ private HashMap prepareUpdateParam( }; } + @Test + public void should_delete_current_user_account() throws Exception { + given() + .header("Authorization", "Token " + token) + .contentType("application/json") + .when() + .delete("/user") + .then() + .statusCode(204); + } + + @Test + public void should_get_401_when_delete_without_token() throws Exception { + given().contentType("application/json").when().delete("/user").then().statusCode(401); + } + @Test public void should_get_401_if_not_login() throws Exception { given() diff --git a/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java b/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java index 39876111c..81e974d9b 100644 --- a/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java +++ b/src/test/java/io/spring/infrastructure/user/MyBatisUserRepositoryTest.java @@ -1,10 +1,20 @@ package io.spring.infrastructure.user; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.comment.Comment; +import io.spring.core.comment.CommentRepository; +import io.spring.core.favorite.ArticleFavorite; +import io.spring.core.favorite.ArticleFavoriteRepository; import io.spring.core.user.FollowRelation; import io.spring.core.user.User; import io.spring.core.user.UserRepository; import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisArticleFavoriteRepository; +import io.spring.infrastructure.repository.MyBatisArticleRepository; +import io.spring.infrastructure.repository.MyBatisCommentRepository; import io.spring.infrastructure.repository.MyBatisUserRepository; +import java.util.Arrays; import java.util.Optional; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -12,9 +22,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; -@Import(MyBatisUserRepository.class) +@Import({ + MyBatisUserRepository.class, + MyBatisArticleRepository.class, + MyBatisCommentRepository.class, + MyBatisArticleFavoriteRepository.class +}) public class MyBatisUserRepositoryTest extends DbTestBase { @Autowired private UserRepository userRepository; + @Autowired private ArticleRepository articleRepository; + @Autowired private CommentRepository commentRepository; + @Autowired private ArticleFavoriteRepository articleFavoriteRepository; private User user; @BeforeEach @@ -70,4 +88,29 @@ public void should_unfollow_user_success() { userRepository.removeRelation(followRelation); Assertions.assertFalse(userRepository.findRelation(user.getId(), other.getId()).isPresent()); } + + @Test + public void should_remove_user_and_cascade_related_data() { + userRepository.save(user); + + Article article = + new Article("test", "desc", "body", Arrays.asList("java"), user.getId()); + articleRepository.save(article); + + Comment comment = new Comment("nice article", user.getId(), article.getId()); + commentRepository.save(comment); + + ArticleFavorite favorite = new ArticleFavorite(article.getId(), user.getId()); + articleFavoriteRepository.save(favorite); + + User other = new User("other@example.com", "other", "123", "", ""); + userRepository.save(other); + userRepository.saveRelation(new FollowRelation(user.getId(), other.getId())); + + userRepository.remove(user); + + Assertions.assertFalse(userRepository.findById(user.getId()).isPresent()); + Assertions.assertFalse(articleRepository.findById(article.getId()).isPresent()); + Assertions.assertFalse(userRepository.findRelation(user.getId(), other.getId()).isPresent()); + } } From 44857ee1f721a3313775b397047d2de64a011cf8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 22:05:43 +0000 Subject: [PATCH 2/2] fix: also delete comments by other users on deleted user's articles Adds commentMapper.deleteByArticleUserId() to clean up comments left by other users on articles owned by the deleted user, preventing orphaned comment rows. Mirrors the existing pattern used for article_favorites. Co-Authored-By: Yubin Jee --- .../spring/infrastructure/mybatis/mapper/CommentMapper.java | 2 ++ .../infrastructure/repository/MyBatisUserRepository.java | 1 + src/main/resources/mapper/CommentMapper.xml | 3 +++ .../infrastructure/user/MyBatisUserRepositoryTest.java | 5 +++++ 4 files changed, 11 insertions(+) diff --git a/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java b/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java index 2ac1dca76..57ea49056 100644 --- a/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java +++ b/src/main/java/io/spring/infrastructure/mybatis/mapper/CommentMapper.java @@ -13,4 +13,6 @@ public interface CommentMapper { void delete(@Param("id") String id); void deleteByUserId(@Param("userId") String userId); + + void deleteByArticleUserId(@Param("userId") String userId); } diff --git a/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java b/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java index 315bb37ac..73950e7f5 100644 --- a/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java +++ b/src/main/java/io/spring/infrastructure/repository/MyBatisUserRepository.java @@ -77,6 +77,7 @@ public void removeRelation(FollowRelation followRelation) { public void remove(User user) { String userId = user.getId(); commentMapper.deleteByUserId(userId); + commentMapper.deleteByArticleUserId(userId); articleFavoriteMapper.deleteByUserId(userId); articleFavoriteMapper.deleteByArticleUserId(userId); articleMapper.deleteArticleTagsByUserId(userId); diff --git a/src/main/resources/mapper/CommentMapper.xml b/src/main/resources/mapper/CommentMapper.xml index c8cdae9de..788ee0b4e 100644 --- a/src/main/resources/mapper/CommentMapper.xml +++ b/src/main/resources/mapper/CommentMapper.xml @@ -18,6 +18,9 @@ delete from comments where user_id = #{userId} + + delete from comments where article_id in (select id from articles where user_id = #{userId}) +