Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9434adc
feat: 소셜 회원가입을 위한 User 도메인 수정
ysw789 Apr 11, 2026
8e6a0b7
feat: 소셜 회원가입 UseCase 구현
ysw789 Apr 11, 2026
9da77cc
feat: 소셜 회원가입 API 엔드포인트 추가
ysw789 Apr 11, 2026
37bc253
test: CreateUserWithSocialUseCase 테스트 추가
ysw789 Apr 11, 2026
f477180
feat: 소셜 회원가입 시 이메일 인증 제거 및 소셜 계정 이메일 자동 사용
ysw789 Apr 11, 2026
2afec7f
feat: 소셜 계정 연동 해제 UseCase 구현
ysw789 Apr 11, 2026
ed3f6c4
feat: 로그인 상태 비밀번호 변경 UseCase 구현
ysw789 Apr 11, 2026
03ca57d
refactor: 소셜 계정 연동 해제 API 경로 파라미터 방식으로 변경
ysw789 Apr 14, 2026
f8bbb9b
feat: 비밀번호 검증 강화 - 소셜 회원가입 사용자 지원
ysw789 Apr 14, 2026
78b5014
feat: 소셜 회원가입 세션 캐시 관리용 Repository 구성
ysw789 Apr 14, 2026
e461bbc
test: 패키지 참조 이슈 해결
ysw789 Apr 14, 2026
259c7f2
refactor: Redis 캐시 리포지토리에서 불필요한 어노테이션 제거
ysw789 Apr 15, 2026
b2b993c
feat: 회원가입 세션 정리를 이벤트 기반 아키텍처로 변경
ysw789 Apr 15, 2026
f8761ed
test: CreateUserWithSocial 테스트 - ApplicationEventPublisher 모킹 및 검증 추가
ysw789 Apr 15, 2026
bb6006e
refactor: 회원가입 세션 상수를 별도 파일로 추출
ysw789 Apr 15, 2026
3dc9557
refactor: 회원가입 트랜잭션 분리 및 이벤트 기반 세션 처리 제거
ysw789 Apr 15, 2026
7c2b546
feat: 회원가입 트랜잭션 로직을 CreateUserTx로 분리
ysw789 Apr 15, 2026
d7ed838
refactor: 일부 구조 개선
ysw789 Apr 15, 2026
636ff5c
refactor: CreateUser에서 트랜잭션 로직을 CreateUserTx로 분리 및 캐시 추상화 통일
ysw789 Apr 15, 2026
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;
Comment thread
ysw789 marked this conversation as resolved.

@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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@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,32 @@
package com.dreamteam.alter.adapter.outbound.user.persistence;

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

@Repository
@RequiredArgsConstructor
public class SignupSessionCacheRepository {
Comment thread
ysw789 marked this conversation as resolved.

private final StringRedisTemplate redisTemplate;

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);
}

public void delete(String key) {
redisTemplate.delete(key);
}

public void deleteAll(List<String> keys) {
if (!keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
Comment thread
ysw789 marked this conversation as resolved.
}
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