Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/docs/asciidoc/users.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,10 @@ operation::user-controller-test/find-user-info[snippets='http-request,curl-reque

operation::user-controller-test/find-me[snippets='http-request,curl-request,request-headers,http-response,response-fields']

[[온보딩-단계-완료]]
=== `PATCH` 온보딩 단계 완료

operation::user-controller-test/complete-step[snippets='http-request,curl-request,request-headers,request-fields,http-response,response-fields']

[[본인-정보-조회]]
=== `GET` 내 정보 수정 (미구현)
1 change: 1 addition & 0 deletions src/main/java/com/chooz/common/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public enum ErrorCode {
DUPLICATE_POLL_CHOICE("복수 투표의 경우 중복된 선택지가 있으면 안 됨"),
NOT_POST_POLL_CHOICE_ID("게시글의 투표 선택지가 아님"),
ONLY_SELF_CAN_CLOSE("작성자 마감의 경우, SELF 마감 방식만이 마감 가능합니다."),
INVALID_ONBOARDING_STEP("유효하지 않은 온보딩 단계."),

//401
EXPIRED_TOKEN("토큰이 만료됐습니다."),
Expand Down
33 changes: 32 additions & 1 deletion src/main/java/com/chooz/user/application/NicknameGenerator.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,49 @@
package com.chooz.user.application;

import com.chooz.user.domain.NicknameAdjectiveRepository;
import com.chooz.user.domain.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigInteger;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

@Component
@RequiredArgsConstructor
public class NicknameGenerator {

private final NicknameAdjectiveRepository nicknameAdjectiveRepository;
private final UserRepository userRepository;

public String generate() {
return nicknameAdjectiveRepository.findRandomNicknameAdjective()
String prefix = nicknameAdjectiveRepository.findRandomNicknameAdjective()
.map(adjective -> adjective.getAdjective() + " 츄")
.orElse("숨겨진 츄");
return makeNickname(prefix);
}
private String makeNickname(String prefix) {
List<String> nickNames = userRepository.findNicknamesByPrefix(prefix);
Set<BigInteger> usedSuffixes = getUsedSuffixes(prefix, nickNames);
return findUsableNickname(prefix, usedSuffixes);
}
private Set<BigInteger> getUsedSuffixes(String prefix, List<String> nickNames) {
Set<BigInteger> usedSuffixes = new TreeSet<>(BigInteger::compareTo);
for(String nickName : nickNames) {
String suffix = nickName.substring(prefix.length());
if(suffix.isEmpty()) {
usedSuffixes.add(BigInteger.ZERO);
}else{
usedSuffixes.add(new BigInteger(suffix));
}
}
return usedSuffixes;
}
private String findUsableNickname(String prefix, Set<BigInteger> usedSuffixes) {
BigInteger suffix = BigInteger.ZERO;
while (usedSuffixes.contains(suffix)) {
suffix = suffix.add(BigInteger.ONE);
}
return suffix.signum() == 0 ? prefix : prefix + suffix;
Copy link
Contributor

Choose a reason for hiding this comment

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

그냥 단순하게 최대값 + 1으로 하는 방식은 어떠신가요?
지금 방식대로 하면 중간에 없는 숫자를 재사용할 수 있어서 좋긴 한데 그냥 단순한게 더 좋지 않나 싶어서 여쭤봅니다
물론 이대로 하셔도 괜찮습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

그럴까하다가 진호님이 좋아할 것 같아서...

}
}
38 changes: 29 additions & 9 deletions src/main/java/com/chooz/user/application/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import com.chooz.common.exception.BadRequestException;
import com.chooz.common.exception.ErrorCode;
import com.chooz.user.domain.User;
import com.chooz.user.domain.UserRepository;
import com.chooz.user.domain.OnboardingStepRepository;
import com.chooz.user.domain.User;
import com.chooz.user.presentation.dto.OnboardingRequest;
import com.chooz.user.presentation.dto.UserInfoResponse;
import com.chooz.user.presentation.dto.UserMyInfoResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;
import java.util.Optional;

@Service
Expand All @@ -19,32 +22,49 @@ public class UserService {

private final UserRepository userRepository;
private final NicknameGenerator nicknameGenerator;
private final OnboardingStepRepository onboardingStepRepository;

@Transactional
public Long createUser(String nickname, String profileImageUrl) {
User user = userRepository.save(User.create(getNickname(nickname), getProfileImage(profileImageUrl)));
User user = userRepository.save(User.create(getOrGenerateNickname(nickname), profileImageUrl));
return user.getId();
}

private String getNickname(String nickname) {
private String getOrGenerateNickname(String nickname) {
return Optional.ofNullable(nickname)
.orElseGet(() -> nicknameGenerator.generate());
}

private String getProfileImage(String profileImageUrl) {
return Optional.ofNullable(profileImageUrl)
.orElse(User.DEFAULT_PROFILE_URL);
.orElseGet(nicknameGenerator::generate);
}

@Transactional(readOnly = true)
public UserInfoResponse findById(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND));
return UserInfoResponse.of(user);
}

@Transactional(readOnly = true)
public UserMyInfoResponse findByMe(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND));
return UserMyInfoResponse.of(user);
}

@Transactional
public UserInfoResponse completeStep(Long userId, OnboardingRequest onboardingRequest) {
if (onboardingRequest.onboardingStep().values().stream().noneMatch(Boolean.TRUE::equals)) {
throw new BadRequestException(ErrorCode.INVALID_ONBOARDING_STEP);
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND));
UpdateOnboardingStep(user, onboardingRequest);
return UserInfoResponse.of(userRepository.findById(userId)
.orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)));
}

private void UpdateOnboardingStep(User user, OnboardingRequest onboardingRequest) {
onboardingRequest.onboardingStep().entrySet().stream()
.filter(step -> Boolean.TRUE.equals(step.getValue()))
.map(Map.Entry::getKey)
.forEach(stepType -> stepType.apply(user.getOnboardingStep()));
}
}
46 changes: 46 additions & 0 deletions src/main/java/com/chooz/user/domain/OnboardingStep.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.chooz.user.domain;

import com.chooz.common.domain.BaseEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;


@Getter
@Entity
@Table(name = "onboarding_step")
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class OnboardingStep extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private boolean welcomeGuide;

private boolean firstVote;

@Builder
public OnboardingStep(Long id, boolean welcomeGuide, boolean firstVote) {
this.id = id;
this.welcomeGuide = welcomeGuide;
this.firstVote = firstVote;
}

public void completeWelcomeGuide() {
this.welcomeGuide = true;
}

public void completeFirstVote() {
this.firstVote = true;
}

public boolean isCompletedAll() {
return welcomeGuide && firstVote;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.chooz.user.domain;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface OnboardingStepRepository extends JpaRepository<OnboardingStep, Long> {}
27 changes: 27 additions & 0 deletions src/main/java/com/chooz/user/domain/OnboardingStepType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.chooz.user.domain;


import java.util.function.Consumer;
import java.util.function.Predicate;

public enum OnboardingStepType {

WELCOME_GUIDE(OnboardingStep::completeWelcomeGuide, OnboardingStep::isWelcomeGuide),
FIRST_VOTE(OnboardingStep::completeFirstVote, OnboardingStep::isFirstVote);

private final Consumer<OnboardingStep> action;
private final Predicate<OnboardingStep> checker;

OnboardingStepType(Consumer<OnboardingStep> action, Predicate<OnboardingStep> checker) {
this.action = action;
this.checker = checker;
}

public void apply(OnboardingStep step) {
this.action.accept(step);
}

public boolean check(OnboardingStep step) {
return this.checker.test(step);
}
}
37 changes: 30 additions & 7 deletions src/main/java/com/chooz/user/domain/User.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
package com.chooz.user.domain;

import com.chooz.common.domain.BaseEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Optional;

@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class User extends BaseEntity {

public static final String DEFAULT_PROFILE_URL = "https://image.chooz.site/default_profile.png";
private static final String DEFAULT_PROFILE_URL = "https://cdn.chooz.site/default_profile.png";

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand All @@ -27,19 +32,37 @@ public class User extends BaseEntity {

private String profileUrl;

@Enumerated(jakarta.persistence.EnumType.STRING)
public Role role;
private boolean notification;

@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "onboarding_step_id", unique = true)
private OnboardingStep onboardingStep;

@Builder
private User(Long id, String nickname, String profileUrl, Role role) {
private User(
Long id,
String nickname,
String profileUrl,
boolean notification,
OnboardingStep onboardingStep
) {
this.id = id;
this.nickname = nickname;
this.profileUrl = profileUrl;
this.role = role;
this.notification = notification;
this.onboardingStep = onboardingStep;
}

public static User create(String nickname, String profileUrl) {
return new User(null, nickname, profileUrl, Role.USER);
return new User(null, nickname, getOrDefaultProfileImage(profileUrl), false, new OnboardingStep());
}

private static String getOrDefaultProfileImage(String profileImageUrl) {
return Optional.ofNullable(profileImageUrl)
.orElse(User.DEFAULT_PROFILE_URL);
}

public boolean hasCompletedOnboarding() {
return onboardingStep != null && onboardingStep.isCompletedAll();
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/chooz/user/domain/UserRepository.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
package com.chooz.user.domain;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("""
SELECT u.nickname
FROM User u
WHERE u.nickname
LIKE CONCAT(:prefix, '%')
""")
List<String> findNicknamesByPrefix(@Param("prefix") String prefix);

}
12 changes: 11 additions & 1 deletion src/main/java/com/chooz/user/presentation/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import com.chooz.auth.domain.UserInfo;
import com.chooz.user.application.UserService;
import com.chooz.user.presentation.dto.OnboardingRequest;
import com.chooz.user.presentation.dto.UserInfoResponse;
import com.chooz.user.presentation.dto.UserMyInfoResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -32,4 +35,11 @@ public ResponseEntity<UserMyInfoResponse> findMyInfo(
return ResponseEntity.ok(userService.findByMe(userInfo.userId()));
}

@PatchMapping("/onboarding")
public ResponseEntity<UserInfoResponse> findUserInfo(
@Valid @RequestBody OnboardingRequest request,
@AuthenticationPrincipal UserInfo userInfo
) {
return ResponseEntity.ok(userService.completeStep(userInfo.userId(), request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.chooz.user.presentation.dto;

import com.chooz.user.domain.OnboardingStepType;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import java.util.Map;

public record OnboardingRequest(
@NotNull
@Size(min = 1)
Map<OnboardingStepType, Boolean> onboardingStep
) {}
Loading