diff --git a/build.gradle b/build.gradle index 46dab2e..117c76d 100644 --- a/build.gradle +++ b/build.gradle @@ -76,6 +76,11 @@ dependencies { //implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' + def mapstructVersion = "1.5.5.Final" + implementation "org.mapstruct:mapstruct:${mapstructVersion}" + annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}" + testAnnotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' developmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/src/main/java/com/universe/uni/controller/CoupleController.java b/src/main/java/com/universe/uni/controller/CoupleController.java new file mode 100644 index 0000000..eb18877 --- /dev/null +++ b/src/main/java/com/universe/uni/controller/CoupleController.java @@ -0,0 +1,43 @@ +package com.universe.uni.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.universe.uni.dto.request.CreateCoupleRequestDto; +import com.universe.uni.dto.request.JoinCoupleRequestDto; +import com.universe.uni.dto.response.CoupleDto; +import com.universe.uni.service.CoupleServiceContract; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/couple") +public class CoupleController { + + private final CoupleServiceContract coupleService; + + @PostMapping("") + public CoupleDto createCoupleBy( + @AuthenticationPrincipal Long userId, + @RequestBody CreateCoupleRequestDto request + ) { + return coupleService.createCoupleByStartDate(userId, request.startDate()); + } + + @PostMapping("join") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void joinCouple( + @AuthenticationPrincipal Long userId, + @RequestBody JoinCoupleRequestDto body + ) { + coupleService.joinCouple(userId, body.inviteCode()); + } +} diff --git a/src/main/java/com/universe/uni/controller/UserController.java b/src/main/java/com/universe/uni/controller/UserController.java index a05bdef..af40a1b 100644 --- a/src/main/java/com/universe/uni/controller/UserController.java +++ b/src/main/java/com/universe/uni/controller/UserController.java @@ -1,23 +1,56 @@ package com.universe.uni.controller; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import com.universe.uni.dto.UserDto; +import com.universe.uni.dto.request.UpdateUserNicknameRequestDto; import com.universe.uni.dto.response.UserWishCouponResponseDto; -import com.universe.uni.service.UserService; +import com.universe.uni.service.CoupleServiceContract; +import com.universe.uni.service.UserServiceContract; import lombok.RequiredArgsConstructor; @RestController +@RequestMapping("api/user") @RequiredArgsConstructor -@RequestMapping("/api/user") public class UserController { - private final UserService userService; + private final UserServiceContract userService; + private final CoupleServiceContract coupleService; + + @PatchMapping("") + @ResponseStatus(HttpStatus.OK) + public UserDto updateUserNickname( + @AuthenticationPrincipal long userId, + @RequestBody UpdateUserNicknameRequestDto requestDto + ) { + return userService.updateUserNickname(userId, requestDto.nickname()); + } + + @PatchMapping(value = "/info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.OK) + public UserDto updateUserProfile( + @AuthenticationPrincipal long userId, + @RequestPart MultipartFile image, + @RequestParam String nickname, + @RequestParam String startDate + ) { + final Long userCoupleId = userService.findUserCoupleId(userId); + coupleService.updateCoupleStartDate(userCoupleId, startDate); + return userService.updateUserNicknameAndImage(userId, "", nickname); + } @GetMapping("/{userId}/wish") @ResponseStatus(HttpStatus.OK) diff --git a/src/main/java/com/universe/uni/domain/InviteCodeStrategy.java b/src/main/java/com/universe/uni/domain/InviteCodeStrategy.java new file mode 100644 index 0000000..7fdfdc3 --- /dev/null +++ b/src/main/java/com/universe/uni/domain/InviteCodeStrategy.java @@ -0,0 +1,5 @@ +package com.universe.uni.domain; + +public interface InviteCodeStrategy { + String generate(); +} diff --git a/src/main/java/com/universe/uni/domain/RandomInviteCodeGenerator.java b/src/main/java/com/universe/uni/domain/RandomInviteCodeGenerator.java new file mode 100644 index 0000000..5a2e809 --- /dev/null +++ b/src/main/java/com/universe/uni/domain/RandomInviteCodeGenerator.java @@ -0,0 +1,35 @@ +package com.universe.uni.domain; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Random; + +import org.springframework.stereotype.Component; + +@Component +public class RandomInviteCodeGenerator implements InviteCodeStrategy { + + public static final int MAX_CODE_LENGTH = 9; + private final Random random; + + public RandomInviteCodeGenerator() throws NoSuchAlgorithmException { + this.random = SecureRandom.getInstanceStrong(); + } + + @Override + public String generate() { + return createRandomString(); + } + + private String createRandomString() { + StringBuilder randomBuf = new StringBuilder(); + for (int i = 0; i < MAX_CODE_LENGTH; i++) { + if (random.nextBoolean()) { + randomBuf.append((char)((random.nextInt(26)) + 97)); + } else { + randomBuf.append(random.nextInt(10)); + } + } + return randomBuf.toString(); + } +} diff --git a/src/main/java/com/universe/uni/domain/entity/Couple.java b/src/main/java/com/universe/uni/domain/entity/Couple.java index a9d231d..30373d2 100644 --- a/src/main/java/com/universe/uni/domain/entity/Couple.java +++ b/src/main/java/com/universe/uni/domain/entity/Couple.java @@ -12,6 +12,7 @@ import org.hibernate.annotations.ColumnDefault; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -21,6 +22,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Couple { + public static final int MAX_HEART_LIMIT = 5; + @Id @Column(name = "couple_id") @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -29,10 +32,45 @@ public class Couple { @Column(name = "start_date", nullable = false) private LocalDate startDate; - @Column(name = "invite_code", nullable = false) + @Column(name = "invite_code", nullable = false, unique = true) private String inviteCode; @Column(name = "heart_token", nullable = false) @ColumnDefault("5") private int heartToken; + + @Builder + public Couple(LocalDate startDate, String inviteCode) { + this.startDate = startDate; + this.inviteCode = inviteCode; + } + + public void updateStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public boolean hasHeartToken() { + return heartToken > 0; + } + + public boolean isMaxHeartToken() { + return this.heartToken >= 5; + } + + public void increaseHeartTokenBy(int amount) { + this.heartToken += amount; + } + + public void changeHeartTokenMaximum() { + if (!isMaxHeartToken()) { + this.heartToken = MAX_HEART_LIMIT; + } + } + + public void decreaseHeartToken() throws IllegalStateException { + if (!this.hasHeartToken()) { + throw new IllegalStateException("Unable to decrease the heart token"); + } + this.heartToken -= 1; + } } diff --git a/src/main/java/com/universe/uni/dto/UserDto.java b/src/main/java/com/universe/uni/dto/UserDto.java new file mode 100644 index 0000000..42e2c94 --- /dev/null +++ b/src/main/java/com/universe/uni/dto/UserDto.java @@ -0,0 +1,20 @@ +package com.universe.uni.dto; + +import java.io.Serializable; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +import com.universe.uni.dto.response.CoupleDto; + +/** + * DTO for {@link com.universe.uni.domain.entity.User} + */ +public record UserDto( + @NotNull Long id, + @Size(max = 10) + String nickname, + String image, + CoupleDto couple +) implements Serializable { +} diff --git a/src/main/java/com/universe/uni/dto/request/CreateCoupleRequestDto.java b/src/main/java/com/universe/uni/dto/request/CreateCoupleRequestDto.java new file mode 100644 index 0000000..b9bc700 --- /dev/null +++ b/src/main/java/com/universe/uni/dto/request/CreateCoupleRequestDto.java @@ -0,0 +1,6 @@ +package com.universe.uni.dto.request; + +public record CreateCoupleRequestDto( + String startDate +) { +} diff --git a/src/main/java/com/universe/uni/dto/request/JoinCoupleRequestDto.java b/src/main/java/com/universe/uni/dto/request/JoinCoupleRequestDto.java new file mode 100644 index 0000000..c87fbb1 --- /dev/null +++ b/src/main/java/com/universe/uni/dto/request/JoinCoupleRequestDto.java @@ -0,0 +1,6 @@ +package com.universe.uni.dto.request; + +public record JoinCoupleRequestDto( + String inviteCode +) { +} diff --git a/src/main/java/com/universe/uni/dto/request/UpdateUserNicknameRequestDto.java b/src/main/java/com/universe/uni/dto/request/UpdateUserNicknameRequestDto.java new file mode 100644 index 0000000..25b00b1 --- /dev/null +++ b/src/main/java/com/universe/uni/dto/request/UpdateUserNicknameRequestDto.java @@ -0,0 +1,6 @@ +package com.universe.uni.dto.request; + +public record UpdateUserNicknameRequestDto( + String nickname +) { +} diff --git a/src/main/java/com/universe/uni/dto/response/CoupleDto.java b/src/main/java/com/universe/uni/dto/response/CoupleDto.java new file mode 100644 index 0000000..24ef77b --- /dev/null +++ b/src/main/java/com/universe/uni/dto/response/CoupleDto.java @@ -0,0 +1,18 @@ +package com.universe.uni.dto.response; + +import java.io.Serializable; +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * DTO for {@link com.universe.uni.domain.entity.Couple} + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record CoupleDto( + Long id, + LocalDate startDate, + String inviteCode, + int heartToken +) implements Serializable { +} \ No newline at end of file diff --git a/src/main/java/com/universe/uni/exception/dto/ErrorType.java b/src/main/java/com/universe/uni/exception/dto/ErrorType.java index fe97a78..8e8d56c 100644 --- a/src/main/java/com/universe/uni/exception/dto/ErrorType.java +++ b/src/main/java/com/universe/uni/exception/dto/ErrorType.java @@ -19,7 +19,10 @@ public enum ErrorType { "요청 시 토큰이 누락되어 토큰 값이 없는 경우입니다."), ALREADY_GAME_CREATED(HttpStatus.BAD_REQUEST, "UE1003", "이미 생성된 승부가 있습니다."), + USER_NOT_EXISTENT(HttpStatus.BAD_REQUEST, "UE1004", "존재하지 않는 유저의 요청"), ALREADY_GAME_DONE(HttpStatus.BAD_REQUEST, "UE1005", "이미 종료된 라운드입니다."), + COUPLE_NOT_EXISTENT(HttpStatus.BAD_REQUEST, "UE1006", "존재하지 않는 커플 id 입니다"), + INVALID_INVITE_CODE(HttpStatus.BAD_REQUEST, "UE1007", "올바르지 않은 초대 코드입니다."), /** * 401 Unauthorized diff --git a/src/main/java/com/universe/uni/mapper/CoupleMapper.java b/src/main/java/com/universe/uni/mapper/CoupleMapper.java new file mode 100644 index 0000000..b8aded2 --- /dev/null +++ b/src/main/java/com/universe/uni/mapper/CoupleMapper.java @@ -0,0 +1,14 @@ +package com.universe.uni.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; +import org.mapstruct.ReportingPolicy; + +import com.universe.uni.domain.entity.Couple; +import com.universe.uni.dto.response.CoupleDto; + +@Mapper(unmappedTargetPolicy = ReportingPolicy.WARN, componentModel = MappingConstants.ComponentModel.SPRING) +public interface CoupleMapper { + + CoupleDto toCoupleDto(Couple couple); +} diff --git a/src/main/java/com/universe/uni/mapper/UserMapper.java b/src/main/java/com/universe/uni/mapper/UserMapper.java new file mode 100644 index 0000000..5345f4c --- /dev/null +++ b/src/main/java/com/universe/uni/mapper/UserMapper.java @@ -0,0 +1,11 @@ +package com.universe.uni.mapper; + +import org.mapstruct.Mapper; + +import com.universe.uni.domain.entity.User; +import com.universe.uni.dto.UserDto; + +@Mapper(componentModel = "spring") +public interface UserMapper { + UserDto toUserDto(User user); +} diff --git a/src/main/java/com/universe/uni/repository/CoupleRepository.java b/src/main/java/com/universe/uni/repository/CoupleRepository.java index dfdf310..8e66c6c 100644 --- a/src/main/java/com/universe/uni/repository/CoupleRepository.java +++ b/src/main/java/com/universe/uni/repository/CoupleRepository.java @@ -1,9 +1,11 @@ package com.universe.uni.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.universe.uni.domain.entity.Couple; public interface CoupleRepository extends JpaRepository { + Optional findByInviteCode(String inviteCode); } - diff --git a/src/main/java/com/universe/uni/service/CoupleService.java b/src/main/java/com/universe/uni/service/CoupleService.java new file mode 100644 index 0000000..31a08db --- /dev/null +++ b/src/main/java/com/universe/uni/service/CoupleService.java @@ -0,0 +1,77 @@ +package com.universe.uni.service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import javax.transaction.Transactional; + +import org.mapstruct.factory.Mappers; +import org.springframework.stereotype.Service; + +import com.universe.uni.domain.InviteCodeStrategy; +import com.universe.uni.domain.entity.Couple; +import com.universe.uni.domain.entity.User; +import com.universe.uni.dto.response.CoupleDto; +import com.universe.uni.exception.BadRequestException; +import com.universe.uni.exception.dto.ErrorType; +import com.universe.uni.mapper.CoupleMapper; +import com.universe.uni.repository.CoupleRepository; +import com.universe.uni.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CoupleService implements CoupleServiceContract { + + private final CoupleRepository coupleRepository; + private final UserRepository userRepository; + private final InviteCodeStrategy inviteCodeStrategy; + private final CoupleMapper coupleMapper = Mappers.getMapper(CoupleMapper.class); + + @Override + @Transactional + public CoupleDto createCoupleByStartDate( + Long userId, + String startDate + ) { + final LocalDate localDate = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE); + final String inviteCode = inviteCodeStrategy.generate(); + final Couple couple = Couple.builder() + .startDate(localDate) + .inviteCode(inviteCode) + .build(); + final User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorType.USER_NOT_EXISTENT)); + user.connectCouple(couple); + return coupleMapper.toCoupleDto(coupleRepository.save(couple)); + } + + @Override + public CoupleDto findCouple(Long coupleId) { + final Couple couple = coupleRepository.findById(coupleId) + .orElseThrow(() -> new BadRequestException(ErrorType.COUPLE_NOT_EXISTENT)); + return coupleMapper.toCoupleDto(couple); + } + + @Override + @Transactional + public void joinCouple(Long userId, String inviteCode) { + final Couple couple = coupleRepository.findByInviteCode(inviteCode) + .orElseThrow(() -> new BadRequestException(ErrorType.INVALID_INVITE_CODE)); + final User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorType.USER_NOT_EXISTENT)); + user.connectCouple(couple); + + } + + @Override + @Transactional + public CoupleDto updateCoupleStartDate(Long coupleId, String startDate) { + final LocalDate date = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE); + final Couple couple = coupleRepository.findById(coupleId) + .orElseThrow(() -> new BadRequestException(ErrorType.COUPLE_NOT_EXISTENT)); + couple.updateStartDate(date); + return coupleMapper.toCoupleDto(couple); + } +} diff --git a/src/main/java/com/universe/uni/service/CoupleServiceContract.java b/src/main/java/com/universe/uni/service/CoupleServiceContract.java new file mode 100644 index 0000000..3ea0cd4 --- /dev/null +++ b/src/main/java/com/universe/uni/service/CoupleServiceContract.java @@ -0,0 +1,21 @@ +package com.universe.uni.service; + +import javax.transaction.Transactional; + +import com.universe.uni.dto.response.CoupleDto; + +public interface CoupleServiceContract { + + CoupleDto createCoupleByStartDate( + Long userId, + String startDate + ); + + CoupleDto findCouple(Long coupleId); + + @Transactional + void joinCouple(Long userId, String inviteCode); + + @Transactional + CoupleDto updateCoupleStartDate(Long coupleId, String startDate); +} diff --git a/src/main/java/com/universe/uni/service/JwtManager.java b/src/main/java/com/universe/uni/service/JwtManager.java index 1cd0c08..c4e065a 100644 --- a/src/main/java/com/universe/uni/service/JwtManager.java +++ b/src/main/java/com/universe/uni/service/JwtManager.java @@ -86,7 +86,7 @@ private Claims getBody(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() - .parseClaimsJwt(token) + .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException exception) { log.error("EXPIRED_JWT_TOKEN"); diff --git a/src/main/java/com/universe/uni/service/UserService.java b/src/main/java/com/universe/uni/service/UserService.java index ed1492b..c3df119 100644 --- a/src/main/java/com/universe/uni/service/UserService.java +++ b/src/main/java/com/universe/uni/service/UserService.java @@ -2,15 +2,20 @@ import java.util.List; +import javax.transaction.Transactional; + +import org.mapstruct.factory.Mappers; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import com.universe.uni.domain.entity.User; import com.universe.uni.domain.entity.WishCoupon; +import com.universe.uni.dto.UserDto; import com.universe.uni.dto.WishCouponDto; import com.universe.uni.dto.response.UserWishCouponResponseDto; +import com.universe.uni.exception.BadRequestException; import com.universe.uni.exception.NotFoundException; import com.universe.uni.exception.dto.ErrorType; +import com.universe.uni.mapper.UserMapper; import com.universe.uni.repository.UserRepository; import com.universe.uni.repository.WishCouponRepository; @@ -18,12 +23,45 @@ @Service @RequiredArgsConstructor -@Transactional -public class UserService { - +public class UserService implements UserServiceContract { private final UserRepository userRepository; private final WishCouponRepository wishCouponRepository; + private final UserMapper userMapper = Mappers.getMapper(UserMapper.class); + + @Override + public Long findUserCoupleId(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorType.USER_NOT_EXISTENT)) + .getCouple() + .getId(); + } + + @Override + @Transactional + public UserDto updateUserNickname( + Long userId, + String nickname + ) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorType.USER_NOT_EXISTENT)); + user.updateNickname(nickname); + return userMapper.toUserDto(user); + } + + @Override + @Transactional + public UserDto updateUserNicknameAndImage( + Long userId, + String imageUrl, + String nickName + ) { + final User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorType.USER_NOT_EXISTENT)); + user.updateNickname(nickName); + return userMapper.toUserDto(user); + } + @Override public UserWishCouponResponseDto getUserWishCouponList(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(ErrorType.NOT_FOUND_USER)); diff --git a/src/main/java/com/universe/uni/service/UserServiceContract.java b/src/main/java/com/universe/uni/service/UserServiceContract.java new file mode 100644 index 0000000..91ca6da --- /dev/null +++ b/src/main/java/com/universe/uni/service/UserServiceContract.java @@ -0,0 +1,23 @@ +package com.universe.uni.service; + +import javax.transaction.Transactional; + +import com.universe.uni.dto.UserDto; +import com.universe.uni.dto.response.UserWishCouponResponseDto; + +public interface UserServiceContract { + + Long findUserCoupleId(Long userId); + + @Transactional + UserDto updateUserNickname(Long userId, String nickname); + + @Transactional + UserDto updateUserNicknameAndImage( + Long userId, + String imageUrl, + String nickname + ); + + UserWishCouponResponseDto getUserWishCouponList(Long userId); +}