From 2a7aa35c29a3ba524d95fc1730a4a6c1eb86f5d5 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:50:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(server):=20MBTI=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5/=ED=86=B5=EA=B3=84=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/controller/GameUserController.java | 21 +++++++++ .../controller/message/GameUserMessage.java | 2 + .../game/dto/request/MbtiResultRequest.java | 21 +++++++++ .../game/dto/response/MbtiResultResponse.java | 19 ++++++++ .../game/dto/response/MbtiStatsResponse.java | 12 +++++ .../dto/response/MbtiTypeCountResponse.java | 11 +++++ .../gdgoc/domain/game/entity/MbtiResult.java | 47 +++++++++++++++++++ .../game/repository/MbtiResultRepository.java | 15 ++++++ .../projection/MbtiTypeCountProjection.java | 6 +++ .../game/service/MbtiResultService.java | 43 +++++++++++++++++ .../V20260306__create_mbti_result.sql | 11 +++++ 11 files changed, 208 insertions(+) create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/request/MbtiResultRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/response/MbtiResultResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/response/MbtiStatsResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/response/MbtiTypeCountResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/game/entity/MbtiResult.java create mode 100644 src/main/java/inha/gdgoc/domain/game/repository/MbtiResultRepository.java create mode 100644 src/main/java/inha/gdgoc/domain/game/repository/projection/MbtiTypeCountProjection.java create mode 100644 src/main/java/inha/gdgoc/domain/game/service/MbtiResultService.java create mode 100644 src/main/resources/db/migration/V20260306__create_mbti_result.sql diff --git a/src/main/java/inha/gdgoc/domain/game/controller/GameUserController.java b/src/main/java/inha/gdgoc/domain/game/controller/GameUserController.java index 53fd5470..c89a23e7 100644 --- a/src/main/java/inha/gdgoc/domain/game/controller/GameUserController.java +++ b/src/main/java/inha/gdgoc/domain/game/controller/GameUserController.java @@ -2,10 +2,16 @@ import static inha.gdgoc.domain.game.controller.message.GameUserMessage.GAME_RANK_RETRIEVED_SUCCESS; import static inha.gdgoc.domain.game.controller.message.GameUserMessage.GAME_RANK_SAVE_SUCCESS; +import static inha.gdgoc.domain.game.controller.message.GameUserMessage.MBTI_RESULT_UPSERT_SUCCESS; +import static inha.gdgoc.domain.game.controller.message.GameUserMessage.MBTI_RESULT_STATS_RETRIEVED_SUCCESS; import inha.gdgoc.domain.game.dto.request.GameUserRequest; +import inha.gdgoc.domain.game.dto.request.MbtiResultRequest; import inha.gdgoc.domain.game.dto.response.GameUserResponse; +import inha.gdgoc.domain.game.dto.response.MbtiResultResponse; +import inha.gdgoc.domain.game.dto.response.MbtiStatsResponse; import inha.gdgoc.domain.game.service.GameUserService; +import inha.gdgoc.domain.game.service.MbtiResultService; import inha.gdgoc.global.dto.response.ApiResponse; import java.util.List; import lombok.RequiredArgsConstructor; @@ -22,6 +28,7 @@ public class GameUserController { private final GameUserService gameUserService; + private final MbtiResultService mbtiResultService; @PostMapping("/result") public ResponseEntity, Void>> saveGameResult( @@ -38,4 +45,18 @@ public ResponseEntity, Void>> getUserRankings return ResponseEntity.ok(ApiResponse.ok(GAME_RANK_RETRIEVED_SUCCESS, response)); } + + @PostMapping("/mbti/result") + public ResponseEntity> upsertMbtiResult( + @RequestBody MbtiResultRequest request + ) { + MbtiResultResponse response = mbtiResultService.upsertMbtiResult(request); + return ResponseEntity.ok(ApiResponse.ok(MBTI_RESULT_UPSERT_SUCCESS, response)); + } + + @GetMapping("/mbti/result/stats") + public ResponseEntity> getMbtiStats() { + MbtiStatsResponse response = mbtiResultService.getMbtiStats(); + return ResponseEntity.ok(ApiResponse.ok(MBTI_RESULT_STATS_RETRIEVED_SUCCESS, response)); + } } diff --git a/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java b/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java index ec30ab02..9938b578 100644 --- a/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java +++ b/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java @@ -3,4 +3,6 @@ public class GameUserMessage { public static final String GAME_RANK_SAVE_SUCCESS = "성공적으로 유저 랭킹 정보를 저장했습니다."; public static final String GAME_RANK_RETRIEVED_SUCCESS = "성공적으로 랭킹 정보를 반환했습니다."; + public static final String MBTI_RESULT_UPSERT_SUCCESS = "성공적으로 MBTI 결과를 저장했습니다."; + public static final String MBTI_RESULT_STATS_RETRIEVED_SUCCESS = "성공적으로 MBTI 통계를 반환했습니다."; } diff --git a/src/main/java/inha/gdgoc/domain/game/dto/request/MbtiResultRequest.java b/src/main/java/inha/gdgoc/domain/game/dto/request/MbtiResultRequest.java new file mode 100644 index 00000000..b898d77f --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/request/MbtiResultRequest.java @@ -0,0 +1,21 @@ +package inha.gdgoc.domain.game.dto.request; + +import inha.gdgoc.domain.game.entity.MbtiResult; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MbtiResultRequest { + private String name; + private String studentId; + private String mbtiType; + + public MbtiResult toEntity() { + return MbtiResult.builder() + .name(name) + .studentId(studentId) + .mbtiType(mbtiType) + .build(); + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiResultResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiResultResponse.java new file mode 100644 index 00000000..0dda2b5e --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiResultResponse.java @@ -0,0 +1,19 @@ +package inha.gdgoc.domain.game.dto.response; + +import inha.gdgoc.domain.game.entity.MbtiResult; +import lombok.Getter; + +@Getter +public class MbtiResultResponse { + private final Long id; + private final String name; + private final String studentId; + private final String mbtiType; + + public MbtiResultResponse(MbtiResult mbtiResult) { + this.id = mbtiResult.getId(); + this.name = mbtiResult.getName(); + this.studentId = mbtiResult.getStudentId(); + this.mbtiType = mbtiResult.getMbtiType(); + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiStatsResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiStatsResponse.java new file mode 100644 index 00000000..6a5eac8c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiStatsResponse.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.game.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MbtiStatsResponse { + private final long totalCount; + private final List typeCounts; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiTypeCountResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiTypeCountResponse.java new file mode 100644 index 00000000..62eddb49 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/MbtiTypeCountResponse.java @@ -0,0 +1,11 @@ +package inha.gdgoc.domain.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class MbtiTypeCountResponse { + private final String mbtiType; + private final long count; +} diff --git a/src/main/java/inha/gdgoc/domain/game/entity/MbtiResult.java b/src/main/java/inha/gdgoc/domain/game/entity/MbtiResult.java new file mode 100644 index 00000000..6a6e1bb1 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/entity/MbtiResult.java @@ -0,0 +1,47 @@ +package inha.gdgoc.domain.game.entity; + +import inha.gdgoc.global.entity.BaseEntity; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "mbti_result", + uniqueConstraints = { + @UniqueConstraint(name = "uk_mbti_result_name_student_id", columnNames = {"name", "student_id"}) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +public class MbtiResult extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "student_id", nullable = false, length = 20) + private String studentId; + + @Column(name = "mbti_type", nullable = false, length = 4) + private String mbtiType; + + public void updateMbtiType(String mbtiType) { + this.mbtiType = mbtiType; + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/repository/MbtiResultRepository.java b/src/main/java/inha/gdgoc/domain/game/repository/MbtiResultRepository.java new file mode 100644 index 00000000..c890595f --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/repository/MbtiResultRepository.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.game.repository; + +import inha.gdgoc.domain.game.entity.MbtiResult; +import inha.gdgoc.domain.game.repository.projection.MbtiTypeCountProjection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface MbtiResultRepository extends JpaRepository { + Optional findByNameAndStudentId(String name, String studentId); + + @Query("select m.mbtiType as mbtiType, count(m) as count from MbtiResult m group by m.mbtiType") + List countByMbtiType(); +} diff --git a/src/main/java/inha/gdgoc/domain/game/repository/projection/MbtiTypeCountProjection.java b/src/main/java/inha/gdgoc/domain/game/repository/projection/MbtiTypeCountProjection.java new file mode 100644 index 00000000..264168ec --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/repository/projection/MbtiTypeCountProjection.java @@ -0,0 +1,6 @@ +package inha.gdgoc.domain.game.repository.projection; + +public interface MbtiTypeCountProjection { + String getMbtiType(); + long getCount(); +} diff --git a/src/main/java/inha/gdgoc/domain/game/service/MbtiResultService.java b/src/main/java/inha/gdgoc/domain/game/service/MbtiResultService.java new file mode 100644 index 00000000..436b1cf0 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/service/MbtiResultService.java @@ -0,0 +1,43 @@ +package inha.gdgoc.domain.game.service; + +import inha.gdgoc.domain.game.dto.request.MbtiResultRequest; +import inha.gdgoc.domain.game.dto.response.MbtiResultResponse; +import inha.gdgoc.domain.game.dto.response.MbtiStatsResponse; +import inha.gdgoc.domain.game.dto.response.MbtiTypeCountResponse; +import inha.gdgoc.domain.game.entity.MbtiResult; +import inha.gdgoc.domain.game.repository.MbtiResultRepository; +import java.util.Comparator; +import java.util.List; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class MbtiResultService { + + private final MbtiResultRepository mbtiResultRepository; + + @Transactional + public MbtiResultResponse upsertMbtiResult(MbtiResultRequest request) { + MbtiResult result = mbtiResultRepository.findByNameAndStudentId(request.getName(), request.getStudentId()) + .map(existing -> { + existing.updateMbtiType(request.getMbtiType()); + return existing; + }) + .orElseGet(() -> mbtiResultRepository.save(request.toEntity())); + + return new MbtiResultResponse(result); + } + + public MbtiStatsResponse getMbtiStats() { + long totalCount = mbtiResultRepository.count(); + List typeCounts = mbtiResultRepository.countByMbtiType().stream() + .map(item -> new MbtiTypeCountResponse(item.getMbtiType(), item.getCount())) + .sorted(Comparator.comparingLong(MbtiTypeCountResponse::getCount).reversed() + .thenComparing(MbtiTypeCountResponse::getMbtiType)) + .toList(); + + return new MbtiStatsResponse(totalCount, typeCounts); + } +} diff --git a/src/main/resources/db/migration/V20260306__create_mbti_result.sql b/src/main/resources/db/migration/V20260306__create_mbti_result.sql new file mode 100644 index 00000000..248c4ffc --- /dev/null +++ b/src/main/resources/db/migration/V20260306__create_mbti_result.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS mbti_result ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + student_id VARCHAR(20) NOT NULL, + mbti_type VARCHAR(4) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_mbti_result_name_student_id + ON mbti_result (name, student_id);