Skip to content

Commit

Permalink
[TNT-102] feat: 트레이너 초대 코드 표시 및 재발급 API 구현 (#16)
Browse files Browse the repository at this point in the history
* [TNT-102] feat: 트레이너, 트레이니 Entity 구현

* [TNT-102] feat: 에러 메시지 추가

* [TNT-102] AuthMember 어노테이션 추가

* [TNT-102] feat: 초대 코드 표시 및 재발급 API 구현

* [TNT-102] chore: test 관련 설정 수정 및 추가

* [TNT-102] feat: Exception 에러 스택 추가할 수 있도록 수정

* [TNT-102] feat: 초대 코드 발급 기능 구현

* [TNT-102] fix: deleted null 찾도록 수정

* [TNT-102] test: 단위 테스트 작성

* [TNT-102] test: 통합 테스트 작성

* [TNT-102] refactor: 컨벤션에 따라 선언 위치 수정

* [TNT-102] test: 초대 코드 재발급 단위 테스트 추가

* [TNT-102] chore: Exception 파일 sonar exclude 추가
  • Loading branch information
ymkim97 authored Jan 16, 2025
1 parent cede34f commit 35b025c
Show file tree
Hide file tree
Showing 18 changed files with 492 additions and 19 deletions.
6 changes: 4 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ sonar {
property "sonar.organization", "yapp-github"
property "sonar.host.url", "https://sonarcloud.io"
property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml"
property "sonar.exclusions", "**/*Application*.java, **/*Config*.java, **/*GlobalExceptionHandler.java, **/Q*.java, **/DynamicQuery.java"
property "sonar.exclusions", "**/*Application*.java, **/*Config*.java, **/*GlobalExceptionHandler.java, **/Q*.java, **/DynamicQuery.java, " +
"**/*Exception.java"
property "sonar.java.coveragePlugin", "jacoco"
}
}
Expand Down Expand Up @@ -132,7 +133,6 @@ ext {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
Expand Down Expand Up @@ -169,7 +169,9 @@ dependencies {
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'

// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'

// MockWebServer
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/com/tnt/application/trainer/TrainerService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.tnt.application.trainer;

import static com.tnt.global.error.model.ErrorMessage.*;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.tnt.domain.trainer.Trainer;
import com.tnt.dto.trainer.response.InvitationCodeResponse;
import com.tnt.global.error.exception.NotFoundException;
import com.tnt.infrastructure.mysql.repository.trainer.TrainerRepository;

import lombok.RequiredArgsConstructor;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TrainerService {

private final TrainerRepository trainerRepository;

public InvitationCodeResponse getInvitationCode(String memberId) {
Trainer trainer = getTrainer(memberId);

return new InvitationCodeResponse(String.valueOf(trainer.getId()), trainer.getInvitationCode());
}

@Transactional
public InvitationCodeResponse reissueInvitationCode(String memberId) {
Trainer trainer = getTrainer(memberId);
trainer.setNewInvitationCode();

return new InvitationCodeResponse(String.valueOf(trainer.getId()), trainer.getInvitationCode());
}

public Trainer getTrainer(String memberId) {
return trainerRepository.findByMemberIdAndDeletedAtIsNull(Long.valueOf(memberId))
.orElseThrow(() -> new NotFoundException(TRAINER_NOT_FOUND));
}
}
45 changes: 45 additions & 0 deletions src/main/java/com/tnt/domain/pt/PtTrainerTrainee.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.tnt.domain.pt;

import java.time.LocalDate;

import com.tnt.global.common.entity.BaseTimeEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Table(name = "pt_trainer_trainee")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PtTrainerTrainee extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false, unique = true)
private Long id;

@Column(name = "trainer_id", nullable = false)
private Long trainerId;

@Column(name = "trainee_id", nullable = false)
private Long traineeId;

@Column(name = "started_at", nullable = false)
private LocalDate startedAt;

@Column(name = "finished_pt_count", nullable = false)
private int finishedPtCount;

@Column(name = "total_pt_count", nullable = false)
private int totalPtCount;

@Column(name = "deleted_at")
private LocalDate deletedAt;
}
83 changes: 83 additions & 0 deletions src/main/java/com/tnt/domain/trainer/Trainer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.tnt.domain.trainer;

import static com.tnt.global.error.model.ErrorMessage.*;
import static io.micrometer.common.util.StringUtils.*;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;

import com.tnt.global.common.entity.BaseTimeEntity;
import com.tnt.global.error.exception.TnTException;

import io.hypersistence.utils.hibernate.id.Tsid;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Table(name = "trainer")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Trainer extends BaseTimeEntity {

public static final int INVITATION_CODE_LENGTH = 8;

@Id
@Tsid
@Column(name = "id", nullable = false, unique = true)
private Long id;

@Column(name = "member_id", nullable = false)
private Long memberId;

@Column(name = "invitation_code", nullable = false, length = INVITATION_CODE_LENGTH)
private String invitationCode;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

@Builder
public Trainer(Long id, Long memberId) {
this.id = id;
this.memberId = Objects.requireNonNull(memberId, TRAINER_NULL_MEMBER_ID.getMessage());
setNewInvitationCode();
}

public void setNewInvitationCode() {
byte[] hashBytes;
StringBuilder sb = new StringBuilder();

String uuidString = UUID.randomUUID().toString();
byte[] uuidStringBytes = uuidString.getBytes(StandardCharsets.UTF_8);

try {
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
hashBytes = messageDigest.digest(uuidStringBytes);
} catch (NoSuchAlgorithmException e) {
throw new TnTException(TRAINER_INVITATION_CODE_GENERATE_FAILED, e);
}

for (int j = 0; j < 4; j++) {
sb.append(String.format("%02x", hashBytes[j]));
}

this.invitationCode = validateInvitationCode(sb.toString().toUpperCase());
}

private String validateInvitationCode(String invitationCode) {
if (isBlank(invitationCode) || invitationCode.length() != INVITATION_CODE_LENGTH) {
throw new IllegalArgumentException(TRAINER_INVALID_INVITATION_CODE.getMessage());
}

return invitationCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.tnt.dto.trainer.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "트레이너의 초대 코드 응답")
public record InvitationCodeResponse(

@Schema(description = "트레이너 id", example = "23984725", type = "string")
String trainerId,

@Schema(description = "트레이너의 초대 코드", example = "2H9DG4X3", type = "string")
String invitationCode
) {

}
18 changes: 18 additions & 0 deletions src/main/java/com/tnt/global/auth/annotation/AuthMember.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.tnt.global.auth.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.springframework.security.core.annotation.AuthenticationPrincipal;

import io.swagger.v3.oas.annotations.Hidden;

@Hidden
@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : username")
public @interface AuthMember {

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ public class NotFoundException extends TnTException {
public NotFoundException(ErrorMessage errorMessage) {
super(errorMessage);
}

public NotFoundException(ErrorMessage errorMessage, Throwable cause) {
super(errorMessage, cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ public class OAuthException extends TnTException {
public OAuthException(ErrorMessage errorMessage) {
super(errorMessage);
}

public OAuthException(ErrorMessage errorMessage, Throwable cause) {
super(errorMessage, cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ public class TnTException extends RuntimeException {
public TnTException(ErrorMessage errorMessage) {
super(errorMessage.getMessage());
}

public TnTException(ErrorMessage errorMessage, Throwable cause) {
super(errorMessage.getMessage(), cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ public class UnauthorizedException extends TnTException {
public UnauthorizedException(ErrorMessage errorMessage) {
super(errorMessage);
}

public UnauthorizedException(ErrorMessage errorMessage, Throwable cause) {
super(errorMessage, cause);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package com.tnt.global.error.handler;

import static com.tnt.global.error.model.ErrorMessage.*;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import static org.springframework.http.HttpStatus.*;

import java.security.SecureRandom;
import java.time.DateTimeException;
Expand Down Expand Up @@ -111,6 +108,14 @@ protected ErrorResponse handleNotFoundException(TnTException exception) {
return new ErrorResponse(exception.getMessage());
}

@ResponseStatus(INTERNAL_SERVER_ERROR)
@ExceptionHandler(IllegalArgumentException.class)
protected ErrorResponse handleIllegalArgumentException(IllegalArgumentException exception) {
log.error(exception.getMessage(), exception);

return new ErrorResponse(exception.getMessage());
}

// 기타 500 예외
@ResponseStatus(INTERNAL_SERVER_ERROR)
@ExceptionHandler(RuntimeException.class)
Expand All @@ -124,7 +129,7 @@ protected ErrorResponse handleRuntimeException(TnTException exception) {
String errorKeyInfo = String.format(ERROR_KEY_FORMAT, sb);
String exceptionTypeInfo = String.format(EXCEPTION_CLASS_TYPE_MESSAGE_FORMANT, exception.getClass());

log.error("{}{}{}", exception.getMessage(), errorKeyInfo, exceptionTypeInfo, exception);
log.error("{} {} {}", exception.getMessage(), errorKeyInfo, exceptionTypeInfo, exception);

return new ErrorResponse(SERVER_ERROR + errorKeyInfo);
}
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/com/tnt/global/error/model/ErrorMessage.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ public enum ErrorMessage {
MATCHING_KEY_NOT_FOUND("매칭키 찾기에 실패했습니다."),
FAILED_TO_VERIFY_ID_TOKEN("Apple ID 토큰 검증에 실패했습니다."),

MEMBER_NOT_FOUND("존재하지 않는 회원입니다.");
MEMBER_NOT_FOUND("존재하지 않는 회원입니다."),

TRAINER_NULL_ID("트레이너 id가 null 입니다."),
TRAINER_NULL_MEMBER_ID("트레이너 member id가 null 입니다."),
TRAINER_INVALID_INVITATION_CODE("초대 코드가 올바르지 않습니다."),
TRAINER_NOT_FOUND("존재하지 않는 트레이너입니다."),
TRAINER_INVITATION_CODE_GENERATE_FAILED("트레이너 초대 코드 생성에 실패했습니다.");

private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.tnt.infrastructure.mysql.repository.trainer;

import java.util.Optional;

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

import com.tnt.domain.trainer.Trainer;

public interface TrainerRepository extends JpaRepository<Trainer, Long> {

Optional<Trainer> findByMemberIdAndDeletedAtIsNull(Long memberId);
}
40 changes: 40 additions & 0 deletions src/main/java/com/tnt/presentation/trainer/TrainerController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.tnt.presentation.trainer;

import static org.springframework.http.HttpStatus.*;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.tnt.application.trainer.TrainerService;
import com.tnt.dto.trainer.response.InvitationCodeResponse;
import com.tnt.global.auth.annotation.AuthMember;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;

@Tag(name = "트레이너", description = "트레이너 관련 API")
@RestController
@RequestMapping("/trainers")
@RequiredArgsConstructor
public class TrainerController {

private final TrainerService trainerService;

@Operation(summary = "트레이너 초대 코드 불러오기 API")
@ResponseStatus(OK)
@GetMapping("/invitation-code")
public InvitationCodeResponse getInvitationCode(@AuthMember String memberId) {
return trainerService.getInvitationCode(memberId);
}

@Operation(summary = "트레이너 초대 코드 재발급 API")
@ResponseStatus(CREATED)
@PutMapping("/invitation-code")
public InvitationCodeResponse reissueInvitationCode(@AuthMember String memberId) {
return trainerService.reissueInvitationCode(memberId);
}
}
Loading

0 comments on commit 35b025c

Please sign in to comment.