diff --git a/build.gradle b/build.gradle index 4c4cc08..bca0236 100644 --- a/build.gradle +++ b/build.gradle @@ -22,9 +22,10 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.google.code.gson:gson' annotationProcessor 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/book/app/infra/config/JpaConfig.java b/src/main/java/com/book/app/infra/config/JpaConfig.java new file mode 100644 index 0000000..e6999a0 --- /dev/null +++ b/src/main/java/com/book/app/infra/config/JpaConfig.java @@ -0,0 +1,10 @@ +package com.book.app.infra.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@Configuration +public class JpaConfig { + +} diff --git a/src/main/java/com/book/app/modules/account/Account.java b/src/main/java/com/book/app/modules/account/Account.java new file mode 100644 index 0000000..d52a2d4 --- /dev/null +++ b/src/main/java/com/book/app/modules/account/Account.java @@ -0,0 +1,73 @@ +package com.book.app.modules.account; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.Objects; + +@ToString +@Getter +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "ACCOUNT") +@Entity +public class Account { + + @Id + @Column(name = "ACCOUNT_ID") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "UID", unique = true, updatable = false, nullable = false) + private String uid; // Supabase 제공 + + @Column(name = "EMAIL", unique = true, nullable = false, length = 100) + private String email; + + @Column(name = "NICKNAME", unique = true, nullable = false, length = 20) + private String nickname; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + @CreatedDate + @Column(name = "JOIN_AT", nullable = false, updatable = false) + private LocalDateTime joinAt; + + @Column(name = "PROFILE_IMG") + private String profileImg; + + private Account(String uid, String email, String nickname) { + this.uid = uid; + this.email = email; + this.nickname = nickname; + } + + public static Account of(String uid, String email, String nickname) { + return new Account(uid, email, nickname); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Account account)) return false; + return id != null && id.equals(account.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public String getProfileImg() { + return this.profileImg == null + ? "" + : this.profileImg; + } +} + diff --git a/src/main/java/com/book/app/modules/account/AccountController.java b/src/main/java/com/book/app/modules/account/AccountController.java new file mode 100644 index 0000000..d4d5495 --- /dev/null +++ b/src/main/java/com/book/app/modules/account/AccountController.java @@ -0,0 +1,46 @@ +package com.book.app.modules.account; + +import com.book.app.modules.account.dto.AccountInfoDto; +import com.book.app.modules.account.dto.SignUpDto; +import com.book.app.modules.account.service.AccountService; +import com.book.app.modules.global.response.SuccessResponseDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@Validated +@RequiredArgsConstructor +@RequestMapping("/accounts") +@RestController +public class AccountController { + + private final AccountService accountService; + + // 사용자 정보 저장 + @PostMapping("/sign-up") + public ResponseEntity signUp(@RequestBody @Valid SignUpDto info) { + log.info("sign-up info = {}",info); + Account newAccount = accountService.saveSignUpInfo(info.toEntity()); + log.info("newAccount = {}", newAccount); + SuccessResponseDto result + = new SuccessResponseDto<>("account", AccountInfoDto.from(newAccount)); + return new ResponseEntity<>(result, HttpStatus.CREATED); + } + + // 사용자 정보 조회 + @GetMapping("/info/{uid}") + public ResponseEntity getInfoAccount(@PathVariable("uid") String uid) { + log.info("uid = {}", uid); + Account getAccount = accountService.getAccountByUid(uid); + log.info("getAccount = {} ", getAccount); + SuccessResponseDto result + = new SuccessResponseDto<>("account", AccountInfoDto.from(getAccount)); + return new ResponseEntity<>(result, HttpStatus.OK); + } + +} diff --git a/src/main/java/com/book/app/modules/account/AccountRepository.java b/src/main/java/com/book/app/modules/account/AccountRepository.java new file mode 100644 index 0000000..1ee4d22 --- /dev/null +++ b/src/main/java/com/book/app/modules/account/AccountRepository.java @@ -0,0 +1,12 @@ +package com.book.app.modules.account; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AccountRepository extends JpaRepository { + Optional findByNickname(String nickname); + Optional findByUid(String uid); +} diff --git a/src/main/java/com/book/app/modules/account/dto/AccountInfoDto.java b/src/main/java/com/book/app/modules/account/dto/AccountInfoDto.java new file mode 100644 index 0000000..95ead51 --- /dev/null +++ b/src/main/java/com/book/app/modules/account/dto/AccountInfoDto.java @@ -0,0 +1,42 @@ +package com.book.app.modules.account.dto; + +import com.book.app.modules.account.Account; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 단일 사용자 데이터 + * @uid : Supabase에서 제공하는 사용자 식별자 + * @email : 로그인을 위한 이메일 + * @nickname : 사용자 별칭 + * @joinAt : DB 저장 날짜 및 시간 + * @profileImg : 프로필 이미지. null 이라면, length 0인 문자열 "" 반환 + */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AccountInfoDto { + + private String uid; + private String email; + private String nickname; + + @JsonProperty("join_at") + private LocalDateTime joinAt; + + @JsonProperty("profile_img") + private String profileImg; + + public static AccountInfoDto from(Account account) { + return AccountInfoDto.builder() + .uid(account.getUid()) + .email(account.getEmail()) + .nickname(account.getNickname()) + .joinAt(account.getJoinAt()) + .profileImg(account.getProfileImg()) + .build(); + } +} diff --git a/src/main/java/com/book/app/modules/account/dto/SignUpDto.java b/src/main/java/com/book/app/modules/account/dto/SignUpDto.java new file mode 100644 index 0000000..b87e0c3 --- /dev/null +++ b/src/main/java/com/book/app/modules/account/dto/SignUpDto.java @@ -0,0 +1,40 @@ +package com.book.app.modules.account.dto; + +import com.book.app.modules.account.Account; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.*; +import org.hibernate.validator.constraints.Length; + +/** + * 회원가입 데이터 + * @uid : Supabase에서 제공하는 사용자 식별자 + * @email : 로그인을 위한 이메일 + * @nickname : 사용자 별칭 + */ +@ToString +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SignUpDto { + + @NotBlank(message = "uid를 입력해주세요.") + private String uid; + + @Email(message = "이메일 형식에 맞춰주세요.") + @NotBlank(message = "이메일을 입력해주세요.") + private String email; + + /** + * 2~20자 길이의 한글, 영어(대소문자), 숫자(0-9) 만 허용 + */ + @NotBlank(message = "닉네임을 입력해주세요.") + @Length(min = 2, max = 20, message = "닉네임 길이는 2자 이상 20자 이하로 작성해주세요.") + @Pattern(regexp = "^[ㄱ-ㅎ가-힣A-Za-z0-9]{2,20}$", message = "닉네임은 한글, 영어(대소문자), 숫자만 허용하며 2자에서 20자 사이어야 합니다.") + private String nickname; + + public Account toEntity() { + return Account.of(this.uid, this.email, this.nickname); + } +} diff --git a/src/main/java/com/book/app/modules/account/service/AccountService.java b/src/main/java/com/book/app/modules/account/service/AccountService.java new file mode 100644 index 0000000..be26ac7 --- /dev/null +++ b/src/main/java/com/book/app/modules/account/service/AccountService.java @@ -0,0 +1,22 @@ +package com.book.app.modules.account.service; + +import com.book.app.modules.account.Account; + +/** + * 사용자 CRUD에 해당하는 기능을 포함합니다. + */ +public interface AccountService { + + /** + * 회원가입을 하는 사용자의 정보(uid, email, nickname)를 DB에 저장합니다. + * 중복된 닉네임이 있는지 검증합니다. + */ + Account saveSignUpInfo(Account account); + + /** + * uid에 해당하는 사용자 정보 조회 + * @param uid + * @return Account + */ + Account getAccountByUid(String uid); +} diff --git a/src/main/java/com/book/app/modules/account/service/AccountServiceImpl.java b/src/main/java/com/book/app/modules/account/service/AccountServiceImpl.java new file mode 100644 index 0000000..3b4f59e --- /dev/null +++ b/src/main/java/com/book/app/modules/account/service/AccountServiceImpl.java @@ -0,0 +1,36 @@ +package com.book.app.modules.account.service; + +import com.book.app.modules.account.Account; +import com.book.app.modules.account.AccountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.crossstore.ChangeSetPersister; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.NoSuchElementException; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class AccountServiceImpl implements AccountService { + + private final AccountRepository accountRepository; + + @Override + @Transactional + public Account saveSignUpInfo(Account account) { + verifyExistsNickname(account); + return accountRepository.save(account); + } + + @Override + public Account getAccountByUid(String uid) { + return accountRepository.findByUid(uid).orElseThrow(() -> new NoSuchElementException("uid에 해당하는 사용자를 찾을 수 없습니다.")); + } + + private void verifyExistsNickname(Account account) { + accountRepository.findByNickname(account.getNickname()).ifPresent(e -> { + throw new IllegalArgumentException("중복된 닉네임입니다."); + }); + } +} diff --git a/src/main/java/com/book/app/modules/global/response/SuccessResponseDto.java b/src/main/java/com/book/app/modules/global/response/SuccessResponseDto.java new file mode 100644 index 0000000..e1b2906 --- /dev/null +++ b/src/main/java/com/book/app/modules/global/response/SuccessResponseDto.java @@ -0,0 +1,19 @@ +package com.book.app.modules.global.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SuccessResponseDto { + + private String status; + private String domain; + private T data; + + public SuccessResponseDto(String domain, T data) { + this.status = "success"; + this.domain = domain; + this.data = data; + } +} diff --git a/src/test/java/com/book/app/SebadogBookServerApplicationTests.java b/src/test/java/com/book/app/SebadogBookServerApplicationTests.java deleted file mode 100644 index 12ccf2f..0000000 --- a/src/test/java/com/book/app/SebadogBookServerApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.book.app; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Profile; - -@Profile("test") -@SpringBootTest -class SebadogAppTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/book/app/infra/MockMvcTest.java b/src/test/java/com/book/app/infra/MockMvcTest.java new file mode 100644 index 0000000..60b6927 --- /dev/null +++ b/src/test/java/com/book/app/infra/MockMvcTest.java @@ -0,0 +1,20 @@ +package com.book.app.infra; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ActiveProfiles("test") +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +public @interface MockMvcTest { +} diff --git a/src/test/java/com/book/app/modules/account/AccountControllerTest.java b/src/test/java/com/book/app/modules/account/AccountControllerTest.java new file mode 100644 index 0000000..30b1d96 --- /dev/null +++ b/src/test/java/com/book/app/modules/account/AccountControllerTest.java @@ -0,0 +1,87 @@ +package com.book.app.modules.account; + +import com.book.app.infra.MockMvcTest; +import com.book.app.modules.account.dto.SignUpDto; +import com.book.app.modules.account.service.AccountService; +import com.google.gson.Gson; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@MockMvcTest +class AccountControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired Gson gson; + + @Autowired AccountService accountService; + + final String commonUrl = "/accounts"; + + @DisplayName("회원가입 API 성공") + @Test + void signUp_success() throws Exception { + + SignUpDto request = new SignUpDto("test", "zzz@naver.com", "test"); + + mockMvc.perform(post(commonUrl +"/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(gson.toJson(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.domain").value("account")) + .andExpect(jsonPath("$.data.uid").value(request.getUid())) + .andExpect(jsonPath("$.data.email").value(request.getEmail())) + .andExpect(jsonPath("$.data.nickname").value(request.getNickname())); + } + + @Disabled + @DisplayName("회원가입 API 실패 - 중복 닉네임") + @Test + void signUp_fail_duplicated_nickname() throws Exception { + Account firstAccount = accountService.saveSignUpInfo(Account.of("first", "test@test.com", "test")); + + SignUpDto request = new SignUpDto("second", "test22@naver.com", firstAccount.getNickname()); + + mockMvc.perform(post(commonUrl +"/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(gson.toJson(request))) + .andExpect(status().isInternalServerError()) + .andExpect(result -> assertTrue(result.getResolvedException() instanceof IllegalArgumentException)) + .andExpect(result -> assertEquals("중복된 닉네임입니다.", result.getResolvedException().getMessage())); + } + + @DisplayName("사용자 조회 API 성공") + @Test + void getInfoAccount_success() throws Exception { + Account expectAccount = accountService.saveSignUpInfo( + Account.of("first", "test@test.com", "test")); + + mockMvc.perform(get(commonUrl +"/info/{uid}",expectAccount.getUid())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.domain").value("account")) + .andExpect(jsonPath("$.data.uid").value(expectAccount.getUid())) + .andExpect(jsonPath("$.data.email").value(expectAccount.getEmail())) + .andExpect(jsonPath("$.data.nickname").value(expectAccount.getNickname())); + } + + @Disabled + @DisplayName("사용자 조회 API 실패 - 존재하지 않는 UID") + @Test + void getInfoAccount_fail_notFoundAccount_byUid() throws Exception { + mockMvc.perform(get(commonUrl +"/info/{uid}","first")) + .andExpect(status().isNotFound()); + } +} \ No newline at end of file