Skip to content
Draft
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 @@ -11,6 +11,7 @@
import com.dreamteam.alter.domain.user.port.inbound.LoginWithPasswordUseCase;
import com.dreamteam.alter.domain.user.port.inbound.LoginWithSocialUseCase;
import com.dreamteam.alter.domain.user.port.inbound.CreateUserUseCase;
import com.dreamteam.alter.domain.user.port.inbound.CreateUserWithSocialUseCase;
import com.dreamteam.alter.domain.user.port.inbound.CheckContactDuplicationUseCase;
import com.dreamteam.alter.domain.user.port.inbound.CheckNicknameDuplicationUseCase;
import com.dreamteam.alter.domain.user.port.inbound.CheckEmailDuplicationUseCase;
Expand Down Expand Up @@ -41,6 +42,9 @@ public class UserPublicController implements UserPublicControllerSpec {
@Resource(name = "createUser")
private final CreateUserUseCase createUser;

@Resource(name = "createUserWithSocial")
private final CreateUserWithSocialUseCase createUserWithSocial;

@Resource(name = "checkContactDuplication")
private final CheckContactDuplicationUseCase checkContactDuplication;

Expand Down Expand Up @@ -95,6 +99,14 @@ public ResponseEntity<CommonApiResponse<GenerateTokenResponseDto>> createUser(
return ResponseEntity.ok(CommonApiResponse.of(createUser.execute(request)));
}

@Override
@PostMapping("/signup-social")
public ResponseEntity<CommonApiResponse<GenerateTokenResponseDto>> createUserWithSocial(
@Valid @RequestBody CreateUserWithSocialRequestDto request
) {
return ResponseEntity.ok(CommonApiResponse.of(createUserWithSocial.execute(request)));
}

@Override
@PostMapping("/exists/nickname")
public ResponseEntity<CommonApiResponse<CheckNicknameDuplicationResponseDto>> checkNicknameDuplication(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ public interface UserPublicControllerSpec {
})
ResponseEntity<CommonApiResponse<GenerateTokenResponseDto>> createUser(@Valid CreateUserRequestDto request);

@Operation(summary = "소셜 계정으로 회원가입을 수행한다")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "소셜 회원 가입 및 로그인 성공 (JWT 응답)"),
@ApiResponse(responseCode = "400", description = "실패 케이스",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(name = "회원 가입 세션이 존재하지 않음", value = "{\"code\" : \"A006\"}"),
@ExampleObject(name = "사용자 닉네임 중복", value = "{\"code\" : \"A008\"}"),
@ExampleObject(name = "사용자 휴대폰 번호 중복", value = "{\"code\" : \"A009\"}"),
@ExampleObject(name = "소셜 플랫폼 ID 중복", value = "{\"code\" : \"A005\"}"),
@ExampleObject(name = "소셜 토큰 만료 (재 로그인 필요)", value = "{\"code\" : \"A007\"}")
}))
})
ResponseEntity<CommonApiResponse<GenerateTokenResponseDto>> createUserWithSocial(@Valid CreateUserWithSocialRequestDto request);

@Operation(summary = "사용자 닉네임 중복 체크")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "닉네임 중복 체크 성공")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ public class UserSelfController implements UserSelfControllerSpec {
@Resource(name = "verifyEmailVerificationCode")
private final VerifyEmailVerificationCodeUseCase verifyEmailVerificationCode;

@Resource(name = "updatePassword")
private final UpdatePasswordUseCase updatePassword;

@Override
@GetMapping
public ResponseEntity<CommonApiResponse<UserSelfInfoResponseDto>> getUserSelfInfo() {
Expand Down Expand Up @@ -154,4 +157,14 @@ public ResponseEntity<CommonApiResponse<VerifyEmailVerificationCodeResponseDto>>
return ResponseEntity.ok(CommonApiResponse.of(verifyEmailVerificationCode.execute(request)));
}

@Override
@PutMapping("/password")
public ResponseEntity<CommonApiResponse<Void>> updatePassword(
@Valid @RequestBody UpdatePasswordRequestDto request
) {
AppActor actor = AppActionContext.getInstance().getActor();
updatePassword.execute(actor, request);
return ResponseEntity.ok(CommonApiResponse.empty());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,25 @@ ResponseEntity<CommonApiResponse<Void>> updateUserSelfCertificate(
})
ResponseEntity<CommonApiResponse<VerifyEmailVerificationCodeResponseDto>> verifyVerificationCode(@RequestBody @Valid VerifyEmailVerificationCodeRequestDto request);

@Operation(summary = "비밀번호 변경", description = "비밀번호가 설정된 사용자는 currentPassword 필수. 소셜 전용 사용자는 생략 가능.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "비밀번호 변경 성공"),
@ApiResponse(responseCode = "400", description = "실패 케이스",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class),
examples = {
@ExampleObject(
name = "현재 비밀번호 불일치",
value = "{\"code\": \"A017\", \"message\": \"현재 비밀번호가 올바르지 않습니다.\"}"
),
@ExampleObject(
name = "비밀번호 형식 오류",
value = "{\"code\": \"A014\", \"message\": \"비밀번호는 8~16자 이내 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.\"}"
)
}
))
})
ResponseEntity<CommonApiResponse<Void>> updatePassword(@RequestBody @Valid UpdatePasswordRequestDto request);

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse;
import com.dreamteam.alter.adapter.inbound.general.user.dto.LinkSocialAccountRequestDto;
import com.dreamteam.alter.application.user.usecase.LinkSocialAccount;
import com.dreamteam.alter.adapter.inbound.general.user.dto.UnlinkSocialAccountRequestDto;
import com.dreamteam.alter.application.aop.AppActionContext;
import com.dreamteam.alter.domain.user.context.AppActor;
import com.dreamteam.alter.domain.user.port.inbound.LinkSocialAccountUseCase;
import com.dreamteam.alter.domain.user.port.inbound.UnlinkSocialAccountUseCase;
import com.dreamteam.alter.domain.user.type.SocialProvider;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand All @@ -15,16 +19,29 @@
@RequiredArgsConstructor
public class UserSocialController implements UserSocialControllerSpec {

private final LinkSocialAccount linkSocialAccount;
@Resource(name = "linkSocialAccount")
private final LinkSocialAccountUseCase linkSocialAccount;

@Resource(name = "unlinkSocialAccount")
private final UnlinkSocialAccountUseCase unlinkSocialAccount;

@Override
@PostMapping("/link")
public ResponseEntity<CommonApiResponse<Void>> linkSocialAccount(
@Valid @RequestBody LinkSocialAccountRequestDto request
) {
AppActor actor = AppActionContext.getInstance().getActor();

linkSocialAccount.execute(actor, request);
return ResponseEntity.ok(CommonApiResponse.empty());
}

@Override
@DeleteMapping("/unlink/{provider}")
public ResponseEntity<CommonApiResponse<Void>> unlinkSocialAccount(
@PathVariable SocialProvider provider
) {
AppActor actor = AppActionContext.getInstance().getActor();
unlinkSocialAccount.execute(actor, new UnlinkSocialAccountRequestDto(provider));
return ResponseEntity.ok(CommonApiResponse.empty());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse;
import com.dreamteam.alter.adapter.inbound.general.user.dto.LinkSocialAccountRequestDto;
import com.dreamteam.alter.domain.user.type.SocialProvider;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
Expand All @@ -11,6 +12,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;

@Tag(name = "사용자 - 소셜 계정 연동")
public interface UserSocialControllerSpec {
Expand Down Expand Up @@ -39,4 +41,25 @@ public interface UserSocialControllerSpec {
))
})
ResponseEntity<CommonApiResponse<Void>> linkSocialAccount(@Valid LinkSocialAccountRequestDto request);

@Operation(summary = "소셜 계정 연동 해제")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "소셜 계정 연동 해제 성공"),
@ApiResponse(responseCode = "400", description = "실패 케이스",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = com.dreamteam.alter.adapter.inbound.common.dto.ErrorResponse.class),
examples = {
@ExampleObject(
name = "연동되지 않은 소셜 플랫폼",
value = "{\"code\": \"A015\", \"message\": \"연동되지 않은 소셜 플랫폼입니다.\"}"
),
@ExampleObject(
name = "마지막 소셜 계정 해제 불가",
value = "{\"code\": \"A016\", \"message\": \"비밀번호가 설정되지 않은 경우 마지막 소셜 계정은 해제할 수 없습니다.\"}"
)
}
))
})
ResponseEntity<CommonApiResponse<Void>> unlinkSocialAccount(@PathVariable SocialProvider provider);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.dreamteam.alter.adapter.inbound.general.user.dto;

import com.dreamteam.alter.domain.user.type.PlatformType;
import com.dreamteam.alter.domain.user.type.SocialProvider;
import com.dreamteam.alter.domain.user.type.UserGender;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "소셜 회원가입 요청 DTO")
public class CreateUserWithSocialRequestDto {

@NotBlank
@Size(max = 64)
@Schema(description = "회원가입 세션 ID", example = "UUID")
private String signupSessionId;

@NotNull
@Schema(description = "소셜 로그인 플랫폼", example = "KAKAO")
private SocialProvider provider;

@Valid
@Schema(description = "OAuth 토큰")
private OauthLoginTokenDto oauthToken;

@Schema(description = "OAuth 인가 코드", example = "authorizationCode")
private String authorizationCode;

@NotNull
@Schema(description = "플랫폼 타입", example = "WEB / NATIVE")
private PlatformType platformType;

@NotBlank
@Size(max = 12)
@Schema(description = "성명", example = "김철수")
private String name;

@NotBlank
@Size(max = 64)
@Schema(description = "닉네임", example = "유땡땡")
private String nickname;

@NotNull
@Schema(description = "성별", example = "GENDER_MALE")
private UserGender gender;

@NotBlank
@Size(min = 8, max = 8)
@Schema(description = "생년월일", example = "YYYYMMDD")
private String birthday;

@AssertTrue(message = "WEB 플랫폼은 authorizationCode가 필수입니다")
private boolean isWebPlatformValid() {
if (platformType != PlatformType.WEB) return true;
return authorizationCode != null && !authorizationCode.isBlank();
}

@AssertTrue(message = "NATIVE 플랫폼은 oauthToken이 필수입니다")
private boolean isNativePlatformValid() {
if (platformType != PlatformType.NATIVE) return true;
return oauthToken != null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.dreamteam.alter.adapter.inbound.general.user.dto;

import com.dreamteam.alter.domain.user.type.SocialProvider;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "소셜 계정 연동 해제 요청 DTO")
public class UnlinkSocialAccountRequestDto {

@NotNull
@Schema(description = "해제할 소셜 플랫폼", example = "KAKAO")
private SocialProvider provider;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.dreamteam.alter.adapter.inbound.general.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "비밀번호 변경 요청 DTO")
public class UpdatePasswordRequestDto {

@Schema(description = "현재 비밀번호 (비밀번호가 설정된 사용자는 필수, 소셜 전용 사용자는 생략 가능)", example = "currentPass1!")
private String currentPassword;

@NotBlank
@Size(min = 8, max = 16)
@Schema(description = "새 비밀번호 (8~16자, 영문·숫자·특수문자 각 1개 이상)", example = "newPass1!")
private String newPassword;

@AssertTrue(message = "새 비밀번호는 현재 비밀번호와 달라야 합니다")
private boolean isNewPasswordDifferent() {
if (currentPassword == null || newPassword == null) return true;
return !currentPassword.equals(newPassword);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.dreamteam.alter.adapter.outbound.user.persistence;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class SignupSessionCacheRepository {

private final StringRedisTemplate redisTemplate;

public void save(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}

public void save(String key, String value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}

public String get(String key) {
return redisTemplate.opsForValue().get(key);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void delete(String key) {
redisTemplate.delete(key);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deleteAll(List<String> keys) {
if (!keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
Comment on lines +29 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

@Transactional(propagation = REQUIRES_NEW)는 Redis 작업에 효과가 없으며, 데이터 불일치를 유발할 수 있습니다.

Redis는 Spring의 JPA 트랜잭션에 참여하지 않으므로 @Transactional 어노테이션이 실제로 Redis 작업에 영향을 주지 않습니다. 또한 CreateUserWithSocial.execute()에서 cacheRepository.deleteAll() 호출 후 authService.generateAuthorization() 또는 authLogRepository.save()가 실패하면, DB는 롤백되지만 Redis 키는 이미 삭제된 상태로 남아 불일치가 발생합니다.

TransactionSynchronizationManager를 사용하여 DB 커밋 후에 Redis 정리가 실행되도록 개선하거나, 어노테이션을 제거하고 호출 측에서 after-commit 훅을 등록하는 방식을 고려해 주세요.

제안: TransactionSynchronization을 활용한 after-commit 패턴
// SignupSessionCacheRepository에서 `@Transactional` 제거
public void deleteAll(List<String> keys) {
    if (!keys.isEmpty()) {
        redisTemplate.delete(keys);
    }
}

// 또는 호출 측(CreateUserWithSocial)에서 after-commit 훅 등록
private void scheduleSessionCleanupAfterCommit(List<String> keys) {
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            `@Override`
            public void afterCommit() {
                cacheRepository.deleteAll(keys);
            }
        }
    );
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/dreamteam/alter/adapter/outbound/user/persistence/SignupSessionCacheRepository.java`
around lines 29 - 39, The `@Transactional`(propagation = Propagation.REQUIRES_NEW)
on SignupSessionCacheRepository.delete and deleteAll is ineffective for Redis
and can cause DB/Redis inconsistency; remove the `@Transactional` annotations from
SignupSessionCacheRepository.delete and deleteAll and instead schedule Redis
deletions to run after DB commit by registering an after-commit hook from the
caller (e.g., from CreateUserWithSocial) using
TransactionSynchronizationManager.registerSynchronization so that
cacheRepository.deleteAll(keys) is invoked in afterCommit; alternatively keep
simple non-transactional delete/deleteAll methods and call them only from an
after-commit callback to ensure Redis cleanup happens only after successful DB
commit.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dreamteam.alter.adapter.outbound.user.persistence;

import com.dreamteam.alter.domain.user.entity.UserSocial;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserSocialJpaRepository extends JpaRepository<UserSocial, Long> {
}
Loading
Loading