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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ dependencies {

// Env
implementation 'io.github.cdimascio:dotenv-java:3.0.0'

// Excel
implementation 'org.apache.poi:poi-ooxml:5.2.5'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public class MockScore extends BaseEntity {
@Column(name = "cumulative", precision = 5, scale = 2)
private BigDecimal cumulative;

/** 과목 카테고리 (예: 국어/수학) -> 국어 1 , 수학 2, 영어 3, 한국사 4, 사회 5, 과학 6 */
/** 과목 카테고리 (예: 국어/수학) -> 국어 1 , 수학 2, 영어 3, 한국사 4, 탐구1 5, 탐구2 6, 제2외국어 7*/
@Column(name = "category", nullable = false)
private Integer category;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import hackerthon.likelion13th.canfly.domain.user.User;
import hackerthon.likelion13th.canfly.global.api.ApiResponse;
import hackerthon.likelion13th.canfly.global.api.SuccessCode;
import hackerthon.likelion13th.canfly.grades.dto.MockCreateRequest;
import hackerthon.likelion13th.canfly.grades.dto.MockCreateResponse;
import hackerthon.likelion13th.canfly.grades.dto.MockRequestDto;
import hackerthon.likelion13th.canfly.grades.dto.MockResponseDto;
import hackerthon.likelion13th.canfly.grades.service.MockService;
Expand Down Expand Up @@ -55,7 +57,7 @@ public ApiResponse<MockResponseDto> createMock(

public ApiResponse<List<MockResponseDto>> getAllMocksOfUser(@AuthenticationPrincipal CustomUserDetails customUserDetails) {
/* providerId 기반으로 User 찾기 */
User user = userService.findUserByProviderId(customUserDetails.getProviderId());
User user = userService.findUserByProviderId(customUserDetails.getUsername());
List<MockResponseDto> allMocks = mockService.getAllMocksByUserId(user.getUid());

return ApiResponse.onSuccess(SuccessCode.MOCK_GET_ALL_SUCCESS, allMocks);
Expand Down Expand Up @@ -117,4 +119,25 @@ public ApiResponse<Boolean> deleteMock(@PathVariable Long mockId) {
return ApiResponse.onSuccess(SuccessCode.MOCK_DELETE_SUCCESS, true);
}

// GPT
@PostMapping("/excel")
@Operation(
summary = "모의고사 등록(엑셀 평가)",
description = "엑셀 수식으로 각 과목의 백분위/등급/누적(%)를 계산한 뒤 저장합니다. " +
"규칙: 수학 3택1, (과탐/사탐) 합산 최대 2, 제2외국어 최대 1. " +
"표준점수 과목에서 계산 결과가 (백분위=0, 등급=0, 누적=0)이면 입력 오류로 처리됩니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "Mock_2011", description = "모의고사 등록이 완료되었습니다."),
})
public ApiResponse<MockCreateResponse> createMockByExcel(
@AuthenticationPrincipal CustomUserDetails customUserDetails,
@RequestBody MockCreateRequest req
) {
User user = userService.findUserByProviderId(customUserDetails.getUsername());

MockCreateResponse res = mockService.createMockByExcel(user.getUid(), req);

return ApiResponse.onSuccess(SuccessCode.MOCK_CREATE_SUCCESS, res);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package hackerthon.likelion13th.canfly.grades.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.*;

import java.util.List;

@Schema(description = "모의고사 생성 요청")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class MockCreateRequest {

@Schema(description = "응시 연도", example = "2025", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Min(2000) @Max(2100)
private Integer examYear;

@Schema(description = "응시 월(3,6,9,11)", example = "9", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Min(1) @Max(12)
private Integer examMonth;

@Schema(description = "학년(고1~3)", example = "3", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Min(1) @Max(3)
private Integer examGrade;

@Schema(description = "과목 입력 목록 (라벨은 엑셀 B열 텍스트와 동일해야 함)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<ScoreInput> inputs;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@Schema(description = "단일 과목 입력 (label=엑셀 B열 텍스트, value=표준점수 또는 등급)")
public static class ScoreInput {
@Schema(example = "국어", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank
private String label;

@Schema(example = "129", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull
private Integer value; // 표준점수 or 등급(영어/한국사/제2외국어)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package hackerthon.likelion13th.canfly.grades.dto;

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

import java.math.BigDecimal;
import java.util.List;

@Schema(description = "모의고사 생성 응답")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class MockCreateResponse {

@Schema(description = "생성된 Mock ID", example = "101")
private Long mockId;

@Schema(description = "응시 연도", example = "2025")
private Integer examYear;

@Schema(description = "응시 월", example = "9")
private Integer examMonth;

@Schema(description = "학년", example = "3")
private Integer examGrade;

@Schema(description = "과목별 계산 결과")
private List<SubjectCalculated> results;

@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
@Schema(description = "과목별 계산 결과")
public static class SubjectCalculated {

@Schema(description = "엑셀 B열 라벨", example = "국어")
private String label;

@Schema(description = "입력값(표준점수 또는 등급)", example = "129")
private Integer input;

@Schema(description = "백분위", example = "99")
private Integer percentile;

@Schema(description = "등급", example = "1")
private Integer grade;

@Schema(description = "누적 백분위(%)", example = "1.25")
private BigDecimal cumulative;

@Schema(description = "카테고리 코드 (1:국어, 2:수학, 3:영어, 4:한국사, 5:탐구1, 6:탐구2, 7:제2외국어)", example = "1")
private Integer category;

@Schema(description = "세부 과목명(엔티티 name에 저장)", example = "국어 / 물리학 Ⅰ / 사회·문화 등")
private String name;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@
public interface MockRepository extends JpaRepository<Mock, Long> {

List<Mock> findByUser(User user);

// 사용자(uid) + 연/월/학년으로 기존 모의고사 존재 여부 확인
Optional<Mock> findByUserUidAndExamYearAndExamMonthAndExamGrade(
String uid, Integer examYear, Integer examMonth, Integer examGrade
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,26 @@
import hackerthon.likelion13th.canfly.domain.user.User;
import hackerthon.likelion13th.canfly.global.api.ErrorCode;
import hackerthon.likelion13th.canfly.global.exception.GeneralException;
import hackerthon.likelion13th.canfly.grades.dto.MockCreateRequest;
import hackerthon.likelion13th.canfly.grades.dto.MockCreateResponse;
import hackerthon.likelion13th.canfly.grades.dto.MockRequestDto;
import hackerthon.likelion13th.canfly.grades.dto.MockResponseDto;
import hackerthon.likelion13th.canfly.grades.repository.MockRepository;
import hackerthon.likelion13th.canfly.grades.repository.MockScoreRepository;
import hackerthon.likelion13th.canfly.login.repository.UserRepository;
import hackerthon.likelion13th.canfly.score.ScoreExcelEngine;
import hackerthon.likelion13th.canfly.score.SubjectCategoryMapper;
import hackerthon.likelion13th.canfly.score.SubjectRegistry;
import hackerthon.likelion13th.canfly.score.SubjectSelectionValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;


Expand All @@ -28,6 +37,8 @@ public class MockService {
private final MockScoreRepository mockScoreRepository;
private final UserRepository userRepository;

private final SubjectSelectionValidator selectionValidator;
private final ScoreExcelEngine excelEngine;

@Transactional
public MockResponseDto createMock(String userId, MockRequestDto mockRequestDto) {
Expand Down Expand Up @@ -151,4 +162,123 @@ private MockResponseDto.MockScoreResponseDto convertToDto(MockScore mockScore) {
// MockResponseDto의 생성자를 호출하여 엔티티를 넘겨줍니다.
return new MockResponseDto.MockScoreResponseDto(mockScore);
}

// 여기서부터 지피티 코드 -> 엑셀 추출 + 업서트 반영
/**
* @param uid 현재 로그인 사용자 UID (또는 providerId 등, 레포지토리에 맞게 변경)
* @param req 모의고사 생성/갱신 요청
* @return 생성 또는 갱신 결과(계산 결과 포함)
*/
@Transactional
public MockCreateResponse createMockByExcel(String uid, MockCreateRequest req) {
// 0) 사용자 조회
User user = userRepository.findByUid(uid)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다. uid=" + uid));

// 1) 원본 입력을 엔진 형태로 변환 + 선택/조합/범위 검증
var rawInputs = req.getInputs().stream()
.map(i -> new ScoreExcelEngine.SubjectInput(i.getLabel(), i.getValue()))
.collect(Collectors.toList());

var validated = selectionValidator.validateToEngineInputs(rawInputs);
var engineInputs = validated.engineInputs();

// 2) 엑셀 평가 (메모리에서만)
var results = excelEngine.evaluateAll(engineInputs);

// 3) 과목 순서(탐구1/탐구2 코드 부여를 위해) - 라벨 → Subject
List<SubjectRegistry.Subject> pickedOrder = engineInputs.stream()
.map(in -> SubjectRegistry.byLabel(in.label()))
.toList();

// 4) 0/0/0 가드: 표준점수 과목에서 백분위=0 & 등급=0 & 누적=0 이면 입력 오류
for (var in : engineInputs) {
SubjectRegistry.Subject subj = SubjectRegistry.byLabel(in.label());
if (subj.getInputType() == SubjectRegistry.InputType.GRADE) continue; // 등급 입력 과목은 스킵

var r = results.get(in.label());
if (r == null) throw new IllegalStateException("엑셀 평가 결과 없음: " + in.label());

int pct = r.percentile() == null ? 0 : r.percentile();
int grd = r.grade() == null ? 0 : r.grade();
boolean cumZero = (r.cumulative() == null) || r.cumulative().compareTo(BigDecimal.ZERO) == 0;

if (pct == 0 && grd == 0 && cumZero) {
throw new IllegalArgumentException("입력 오류로 판단됩니다. 과목='" + in.label() + "', 입력값=" + in.value()
+ " (계산 결과: 백분위=0, 등급=0, 누적=0)");
}
}

// 5) 기존 Mock 조회 (있으면 재사용)
Mock mock = mockRepository.findByUserUidAndExamYearAndExamMonthAndExamGrade(
user.getUid(), req.getExamYear(), req.getExamMonth(), req.getExamGrade()
).orElseGet(() ->
Mock.builder()
.examYear(req.getExamYear())
.examMonth(req.getExamMonth())
.examGrade(req.getExamGrade())
.user(user)
.build()
);

// 6) 자식 전량 교체: 기존 점수들 제거 후, 이번 계산 결과로 다시 채움
// orphanRemoval = true 이므로 리스트에서 제거하면 DB에서도 삭제됨
mock.getScoreLists().clear();

List<MockCreateResponse.SubjectCalculated> responseItems = new ArrayList<>();
for (var in : engineInputs) {
String label = in.label();
Integer inputVal = toInt(in.value());
var subj = SubjectRegistry.byLabel(label);
var r = results.get(label);

Integer idxInSciSoc = SubjectCategoryMapper.sciSocIndex(subj, pickedOrder);
int categoryCode = SubjectCategoryMapper.toCategoryCode(subj, idxInSciSoc);

Integer grade = (r.grade() != null) ? r.grade()
: (subj.getInputType() == SubjectRegistry.InputType.GRADE ? inputVal : null);
if (grade == null) throw new IllegalArgumentException("등급 계산값을 결정할 수 없습니다. 과목='" + label + "'");

MockScore ms = MockScore.builder()
.standardScore(subj.getInputType() == SubjectRegistry.InputType.SCORE ? inputVal : null)
.percentile(r.percentile())
.grade(grade)
.cumulative(scale2(r.cumulative()))
.category(categoryCode)
.name(label)
.build();

mock.addMockScore(ms);

responseItems.add(
MockCreateResponse.SubjectCalculated.builder()
.label(label).input(inputVal)
.percentile(r.percentile()).grade(grade).cumulative(scale2(r.cumulative()))
.category(categoryCode).name(label)
.build()
);
}

// 7) 저장 (신규면 insert, 기존이면 update)
Mock saved = mockRepository.save(mock);

// 8) 응답 DTO 구성
return MockCreateResponse.builder()
.mockId(saved.getId())
.examYear(saved.getExamYear())
.examMonth(saved.getExamMonth())
.examGrade(saved.getExamGrade())
.results(responseItems)
.build();
}

private Integer toInt(Object v) {
if (v == null) return null;
if (v instanceof Number n) return n.intValue();
return Integer.valueOf(v.toString());
}

private BigDecimal scale2(BigDecimal v) {
return (v == null) ? null : v.setScale(2, java.math.RoundingMode.HALF_UP);
}
}
Loading