Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,28 @@
import com.gachi.be.domain.auth.service.AuthRateLimitService;
import com.gachi.be.domain.auth.service.AuthenticatedUserResolver;
import com.gachi.be.domain.auth.service.ClientIpExtractor;
import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus;
import com.gachi.be.domain.newsletter.repository.NewsletterRepository;
import com.gachi.be.domain.user.dto.request.ChangeLanguageRequest;
import com.gachi.be.domain.user.dto.request.ChangeNotificationRequest;
import com.gachi.be.domain.user.dto.request.EmailChangeCodeSendRequest;
import com.gachi.be.domain.user.dto.request.EmailChangeRequest;
import com.gachi.be.domain.user.dto.request.EmailChangeVerifyRequest;
import com.gachi.be.domain.user.dto.request.PasswordChangeRequest;
import com.gachi.be.domain.user.dto.request.ProfileUpdateRequest;
import com.gachi.be.domain.user.dto.request.UserWithdrawalRequest;
import com.gachi.be.domain.user.dto.response.EmailChangeResponse;
import com.gachi.be.domain.user.dto.response.ProfileUpdateResponse;
import com.gachi.be.domain.user.dto.response.UserMeResponse;
import com.gachi.be.domain.user.entity.User;
import com.gachi.be.domain.user.repository.UserRepository;
import com.gachi.be.domain.user.service.UserProfileService;
import com.gachi.be.global.api.ApiResponse;
import com.gachi.be.global.code.SuccessCode;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

/** 로그인 사용자 기준 내 정보 조회 API를 제공한다. */
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
Expand All @@ -41,8 +34,6 @@ public class UserController {
private final AuthRateLimitService authRateLimitService;
private final ClientIpExtractor clientIpExtractor;
private final UserProfileService userProfileService;
private final UserRepository userRepository;
private final NewsletterRepository newsletterRepository;

@Operation(
summary = "사용자 내 정보 조회",
Expand Down Expand Up @@ -78,50 +69,21 @@ public ApiResponse<UserMeResponse> getMyInfo(
마이페이지에서 내가 회원가입 시에 설정했떤 언어를 변경할 수 있습니다. 해당 언어를 변경한 뒤에 스캔된 문서들은 전부 해당 언어로 번역됩니다.
""")
@PatchMapping("/me/language")
@Transactional
public ApiResponse<Void> changeLanguage(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@RequestBody @Valid ChangeLanguageRequest request) {

User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader);

String previousLanguage = user.getLanguageCode();
String newLanguage = request.languageCode();

if (Objects.equals(previousLanguage, newLanguage)) {
return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null);
}

user.updateLanguage(newLanguage);
userRepository.save(user);

// 진행 중인 파이프라인 FAILED 처리
int cancelledCount =
newsletterRepository.cancelInProgressByUserId(
user.getId(),
List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING),
NewsletterStatus.FAILED,
request.languageCode());

log.info(
"[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}",
user.getId(),
previousLanguage,
newLanguage,
cancelledCount);

userProfileService.changeLanguage(user, request);
return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null);
}

@Operation(summary = "사용자 알림 설정 변경", description = "마이페이지에서 알림 수신 단계를 변경합니다.")
@PatchMapping("/me/notification")
@Transactional
public ApiResponse<Void> changeNotificationPreference(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@RequestBody @Valid ChangeNotificationRequest request) {
User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader);
user.updateNotificationPreference(request.notificationPreference());
userRepository.save(user);
userProfileService.changeNotificationPreference(user, request);
return ApiResponse.success(SuccessCode.USER_NOTIFICATION_UPDATED, null);
}

Expand Down Expand Up @@ -178,4 +140,14 @@ public ApiResponse<Void> changePassword(
userProfileService.changePassword(user, request);
return ApiResponse.success(SuccessCode.USER_PASSWORD_UPDATED, null);
}

@Operation(summary = "회원 탈퇴", description = "현재 비밀번호를 확인한 후 계정을 탈퇴 처리합니다.")
@DeleteMapping("/me")
public ApiResponse<Void> withdraw(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@RequestBody @Valid UserWithdrawalRequest request) {
User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader);
userProfileService.withdraw(user, request);
return ApiResponse.success(SuccessCode.USER_WITHDRAWAL_SUCCESS, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.gachi.be.domain.user.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record UserWithdrawalRequest(@NotBlank @Size(max = 100) String currentPassword) {}
8 changes: 8 additions & 0 deletions src/main/java/com/gachi/be/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ public boolean isActive() {
return status == UserStatus.ACTIVE;
}

public void withdraw(OffsetDateTime withdrawnAt) {
if (withdrawnAt == null) {
throw new IllegalArgumentException("withdrawnAt은 비어 있을 수 없습니다.");
}
this.status = UserStatus.WITHDRAWN;
this.deletedAt = withdrawnAt;
}
Comment on lines +126 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# User 엔티티의 버전/동적 업데이트 설정과 사용자 변경 경로의 잠금 사용 여부를 확인합니다.
ast-grep outline src/main/java/com/gachi/be/domain/user/entity/User.java --items all
rg -n -C3 '`@Version`|`@DynamicUpdate`|findByIdWithLock|changeLanguage|changeNotificationPreference' \
  src/main/java/com/gachi/be/domain/user

Repository: GACHI-Project/GACHI-BE

Length of output: 4609


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== User.java relevant section =="
sed -n '1,220p' src/main/java/com/gachi/be/domain/user/entity/User.java

echo "== UserProfileService relevant sections =="
sed -n '1,260p' src/main/java/com/gachi/be/domain/user/service/UserProfileService.java

echo "== UserRepository =="
sed -n '1,90p' src/main/java/com/gachi/be/domain/user/repository/UserRepository.java

echo "== Controller relevant sections =="
sed -n '70,150p' src/main/java/com/gachi/be/domain/user/api/controller/UserController.java

echo "== Search all user state/status persistence paths =="
rg -n -C2 'setLanguage|setNotification|Status|deletedAt|withdraw|updateProfile|changeEmail|updateLanguage|updateNotification|resolvedActiveUser|findAllById|save\(|getOne|getReference' \
  src/main/java/com/gachi/be/domain/user || true

echo "== Behavioral probe for SQL UPDATE semantics in code if JPA annotations exist =="
python3 - <<'PY'
from pathlib import Path
p = Path('src/main/java/com/gachi/be/domain/user/service/UserProfileService.java')
text = p.read_text()
for name in ['changeLanguage', 'changeNotificationPreference', 'findActiveUserWithLock']:
    idx=text.find(name)
    print(f'--- {name} @ {idx} ---')
    print(text[idx:idx+1200])
PY

Repository: GACHI-Project/GACHI-BE

Length of output: 43986


탈퇴 상태가 동시 프로필 변경으로 되돌아갈 수 있습니다.

withdraw는 비관적 잠금으로 다시 읽지만, changeLanguage·changeNotificationPreference는 잠금/버전 검사 없이 먼저 읽은 User를 저장합니다. @Version도 없어 탈퇴 커밋 뒤 오래된 엔티티의 flush가 status=ACTIVE, deletedAt=null을 덮어써 계정을 재활성화될 수 있습니다. @Version 기반 낙관적 잠금을 추가하거나 모든 사용자 변경 경로를 동일한 잠금 전략으로 통일하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/gachi/be/domain/user/entity/User.java` around lines 126 -
132, User 엔티티의 withdraw, changeLanguage, changeNotificationPreference 동시 변경 경로에
일관된 동시성 제어를 적용하세요. `@Version` 기반 낙관적 잠금을 User에 추가하거나 모든 변경 메서드가 동일한 비관적 잠금으로 최신
엔티티를 읽고 저장하도록 통일해, 탈퇴 커밋 이후 오래된 엔티티가 status와 deletedAt을 덮어쓰지 않게 하세요.


public void resetPassword(String passwordHash, OffsetDateTime passwordUpdatedAt) {
if (passwordHash == null || passwordHash.isBlank()) {
throw new IllegalArgumentException("passwordHash는 비어 있을 수 없습니다.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@
import com.gachi.be.domain.auth.service.EmailVerificationStore;
import com.gachi.be.domain.auth.service.impl.NoopAuthMailService;
import com.gachi.be.domain.auth.service.password.PasswordPolicyValidator;
import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus;
import com.gachi.be.domain.newsletter.repository.NewsletterRepository;
import com.gachi.be.domain.notification.entity.PushDeviceToken;
import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository;
import com.gachi.be.domain.user.dto.request.ChangeLanguageRequest;
import com.gachi.be.domain.user.dto.request.ChangeNotificationRequest;
import com.gachi.be.domain.user.dto.request.EmailChangeCodeSendRequest;
import com.gachi.be.domain.user.dto.request.EmailChangeRequest;
import com.gachi.be.domain.user.dto.request.EmailChangeVerifyRequest;
import com.gachi.be.domain.user.dto.request.PasswordChangeRequest;
import com.gachi.be.domain.user.dto.request.ProfileUpdateRequest;
import com.gachi.be.domain.user.dto.request.UserWithdrawalRequest;
import com.gachi.be.domain.user.dto.response.EmailChangeResponse;
import com.gachi.be.domain.user.dto.response.ProfileUpdateResponse;
import com.gachi.be.domain.user.entity.User;
Expand All @@ -24,7 +31,9 @@
import com.gachi.be.global.exception.BusinessException;
import com.gachi.be.global.exception.ExternalApiException;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
Expand All @@ -40,12 +49,44 @@
public class UserProfileService {
private final UserRepository userRepository;
private final AuthRefreshTokenRepository authRefreshTokenRepository;
private final NewsletterRepository newsletterRepository;
private final PushDeviceTokenRepository pushDeviceTokenRepository;
private final PasswordEncoder passwordEncoder;
private final AuthMailService authMailService;
private final EmailVerificationStore emailVerificationStore;
private final AuthProperties authProperties;
private final PasswordPolicyValidator passwordPolicyValidator;

@Transactional
public void changeLanguage(User user, ChangeLanguageRequest request) {
User currentUser = findActiveUserWithLock(user.getId());
String previousLanguage = currentUser.getLanguageCode();
String newLanguage = request.languageCode();
if (Objects.equals(previousLanguage, newLanguage)) {
return;
}

currentUser.updateLanguage(newLanguage);
int cancelledCount =
newsletterRepository.cancelInProgressByUserId(
currentUser.getId(),
List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING),
NewsletterStatus.FAILED,
newLanguage);
log.info(
"[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}",
currentUser.getId(),
previousLanguage,
newLanguage,
cancelledCount);
}

@Transactional
public void changeNotificationPreference(User user, ChangeNotificationRequest request) {
User currentUser = findActiveUserWithLock(user.getId());
currentUser.updateNotificationPreference(request.notificationPreference());
}

@Transactional
public ProfileUpdateResponse updateProfile(User user, ProfileUpdateRequest request) {
User currentUser = findActiveUserWithLock(user.getId());
Expand Down Expand Up @@ -87,6 +128,20 @@ public void changePassword(User user, PasswordChangeRequest request) {
revokeActiveRefreshTokens(currentUser.getId());
}

@Transactional
public void withdraw(User user, UserWithdrawalRequest request) {
User currentUser = findActiveUserWithLock(user.getId());
if (!passwordEncoder.matches(request.currentPassword(), currentUser.getPasswordHash())) {
throw new BusinessException(ErrorCode.AUTH_INVALID_CREDENTIALS);
}

currentUser.withdraw(OffsetDateTime.now());
revokeActiveRefreshTokens(currentUser.getId());
pushDeviceTokenRepository
.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(currentUser.getId())
.forEach(PushDeviceToken::softDelete);
}

@Transactional
public EmailSendResponse sendEmailChangeCode(User user, EmailChangeCodeSendRequest request) {
User currentUser = findActiveUser(user.getId());
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/gachi/be/global/code/SuccessCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public enum SuccessCode {
USER_EMAIL_CHANGE_VERIFIED(HttpStatus.OK, "USER2005", "이메일 변경 인증이 완료되었습니다."),
USER_EMAIL_UPDATED(HttpStatus.OK, "USER2006", "이메일이 변경되었습니다."),
USER_PASSWORD_UPDATED(HttpStatus.OK, "USER2007", "비밀번호가 변경되었습니다."),
USER_WITHDRAWAL_SUCCESS(HttpStatus.OK, "USER2008", "회원 탈퇴가 완료되었습니다."),
NOTIFICATION_LIST_SUCCESS(HttpStatus.OK, "NOTI2001", "알림 목록 조회에 성공하였습니다."),
NOTIFICATION_UNREAD_COUNT_SUCCESS(HttpStatus.OK, "NOTI2002", "미읽음 알림 수 조회에 성공하였습니다."),
NOTIFICATION_READ_SUCCESS(HttpStatus.OK, "NOTI2003", "알림 읽음 처리에 성공하였습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.gachi.be.domain.user.api.controller;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
Expand All @@ -10,6 +11,9 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gachi.be.domain.auth.service.AuthMailService;
import com.gachi.be.domain.notification.entity.PushDeviceToken;
import com.gachi.be.domain.notification.entity.enums.PushPlatform;
import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository;
import com.gachi.be.domain.user.entity.User;
import com.gachi.be.domain.user.entity.enums.NotificationPreference;
import com.gachi.be.domain.user.entity.enums.UserStatus;
Expand Down Expand Up @@ -53,6 +57,7 @@ class UserControllerIntegrationTest {
@Autowired private ObjectMapper objectMapper;
@Autowired private WebApplicationContext webApplicationContext;
@Autowired private UserRepository userRepository;
@Autowired private PushDeviceTokenRepository pushDeviceTokenRepository;
@Autowired private PasswordEncoder passwordEncoder;
@Autowired private CapturingAuthMailService capturingAuthMailService;

Expand Down Expand Up @@ -85,6 +90,47 @@ void updateProfileChangesNameAndPhoneNumber() throws Exception {
assertThat(updatedUser.getPhoneNumber()).isEqualTo("01022223333");
}

@Test
void changeLanguageUpdatesLockedActiveUser() throws Exception {
String loginId = "language_change_user";
createUser(loginId, "language-change@gachi.com", "01010001014", UserStatus.ACTIVE);
String accessToken = loginAccessToken(loginId, "Policy12!");

mockMvc
.perform(
patch("/api/v1/users/me/language")
.header("Authorization", bearer(accessToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("languageCode", "US"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("USER2001"));

User user = userRepository.findByLoginId(loginId).orElseThrow();
assertThat(user.getLanguageCode()).isEqualTo("US");
}

@Test
void changeNotificationPreferenceUpdatesLockedActiveUser() throws Exception {
String loginId = "notification_change_user";
createUser(loginId, "notification-change@gachi.com", "01010001015", UserStatus.ACTIVE);
String accessToken = loginAccessToken(loginId, "Policy12!");

mockMvc
.perform(
patch("/api/v1/users/me/notification")
.header("Authorization", bearer(accessToken))
.contentType(MediaType.APPLICATION_JSON)
.content(
objectMapper.writeValueAsString(
Map.of("notificationPreference", NotificationPreference.OFF))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("USER2002"));

User user = userRepository.findByLoginId(loginId).orElseThrow();
assertThat(user.getNotificationPreference()).isEqualTo(NotificationPreference.OFF);
assertThat(user.isNotificationEnabled()).isFalse();
}

@Test
void emailChangeCompletesAndRevokesRefreshToken() throws Exception {
String loginId = "email_change_user";
Expand Down Expand Up @@ -233,6 +279,62 @@ void passwordChangeRejectsDangerousPasswordStrength() throws Exception {
.andExpect(jsonPath("$.code").value("AUTH4009"));
}

@Test
void withdrawalChangesAccountStatusAndInvalidatesTokens() throws Exception {
String loginId = "withdrawal_user";
createUser(loginId, "withdrawal@gachi.com", "01010001012", UserStatus.ACTIVE);
User user = userRepository.findByLoginId(loginId).orElseThrow();
JsonNode loginBody = login(loginId, "Policy12!");
String accessToken = loginBody.path("result").path("accessToken").asText();
String refreshToken = loginBody.path("result").path("refreshToken").asText();
PushDeviceToken pushToken =
pushDeviceTokenRepository.saveAndFlush(
PushDeviceToken.builder()
.userId(user.getId())
.platform(PushPlatform.ANDROID)
.token("withdrawal-push-token")
.tokenHash("a".repeat(64))
.deviceId("withdrawal-device")
.appVersion("1.0.0")
.build());

withdraw(accessToken, "Policy12!")
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("USER2008"));

User withdrawnUser = userRepository.findById(user.getId()).orElseThrow();
assertThat(withdrawnUser.getStatus()).isEqualTo(UserStatus.WITHDRAWN);
assertThat(withdrawnUser.getDeletedAt()).isNotNull();

PushDeviceToken deletedPushToken =
pushDeviceTokenRepository.findById(pushToken.getId()).orElseThrow();
assertThat(deletedPushToken.isEnabled()).isFalse();
assertThat(deletedPushToken.getDeletedAt()).isNotNull();

reissue(refreshToken)
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTH4014"));
mockMvc
.perform(get("/api/v1/users/me").header("Authorization", bearer(accessToken)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("AUTH4031"));
}

@Test
void withdrawalRejectsWrongCurrentPassword() throws Exception {
String loginId = "withdrawal_wrong_password";
createUser(loginId, "withdrawal-wrong-password@gachi.com", "01010001013", UserStatus.ACTIVE);
String accessToken = loginAccessToken(loginId, "Policy12!");

withdraw(accessToken, "Wrong12!")
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTH4011"));

User user = userRepository.findByLoginId(loginId).orElseThrow();
assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE);
assertThat(user.getDeletedAt()).isNull();
}

private org.springframework.test.web.servlet.ResultActions sendEmailChangeCode(
String accessToken, String email, String currentPassword) throws Exception {
return mockMvc.perform(
Expand Down Expand Up @@ -280,6 +382,15 @@ private org.springframework.test.web.servlet.ResultActions changePassword(
newPasswordConfirm))));
}

private org.springframework.test.web.servlet.ResultActions withdraw(
String accessToken, String currentPassword) throws Exception {
return mockMvc.perform(
delete("/api/v1/users/me")
.header("Authorization", bearer(accessToken))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("currentPassword", currentPassword))));
}

private org.springframework.test.web.servlet.ResultActions reissue(String refreshToken)
throws Exception {
return mockMvc.perform(
Expand Down
Loading