diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java index 2028b825f..efb4f8f07 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -38,14 +38,14 @@ ResponseEntity migrateSheet( @Operation( summary = "시트 불러오기 전 부원 목록을 미리본다", description = """ - 스프레드시트 URL을 읽어 등록 예정인 부원 목록을 JSON으로 반환합니다. + PUT /clubs/{clubId}/sheet 로 AI 분석 및 등록이 완료된 스프레드시트를 읽어 + 등록 예정인 부원 목록을 JSON으로 반환합니다. 이 API는 데이터를 저장하지 않고 미리보기 용도로만 사용합니다. """ ) @PostMapping("/{clubId}/sheet/import/preview") ResponseEntity previewPreMembers( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody SheetImportRequest request, @UserId Integer requesterId ); diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java index e7b8c0b56..bcceb2161 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -43,11 +43,10 @@ public ResponseEntity migrateSheet( @Override public ResponseEntity previewPreMembers( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody SheetImportRequest request, @UserId Integer requesterId ) { SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( - clubId, requesterId, request.spreadsheetUrl() + clubId, requesterId ); return ResponseEntity.ok(response); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java index 3cee58933..5dbab27af 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java @@ -21,11 +21,25 @@ public interface ClubRepository extends Repository { """) Optional findById(@Param(value = "id") Integer id); + @Query(value = """ + SELECT c + FROM Club c + LEFT JOIN FETCH c.university + LEFT JOIN FETCH c.clubRecruitment cr + WHERE c.id = :id + """) + Optional findByIdWithUniversity(@Param(value = "id") Integer id); + default Club getById(Integer id) { return findById(id).orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); } + default Club getByIdWithUniversity(Integer id) { + return findByIdWithUniversity(id).orElseThrow(() -> + CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); + } + List findAll(); Club save(Club club); diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java index 42a0975a7..d49f1ddd7 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -53,28 +55,55 @@ public class SheetImportService { private final ChatRoomMembershipService chatRoomMembershipService; private final ClubPermissionValidator clubPermissionValidator; private final PlatformTransactionManager transactionManager; + private final ObjectMapper objectMapper; public SheetImportPreviewResponse previewPreMembersFromSheet( Integer clubId, - Integer requesterId, - String spreadsheetUrl + Integer requesterId ) { clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = resolveClubWithRegisteredSheet(clubId); + String spreadsheetId = club.getGoogleSheetId(); + SheetColumnMapping mapping = resolveRegisteredMemberListMapping(club); + SheetImportSource source = loadSheetImportSource(spreadsheetId, mapping); + SheetImportPlan plan = buildImportPlan( + clubId, + club, + source.members(), + source.warnings() + ); + return SheetImportPreviewResponse.of(plan.previewMembers(), plan.warnings()); + } - String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl); - SheetHeaderMapper.SheetAnalysisResult analysis = - sheetHeaderMapper.analyzeAllSheets(spreadsheetId); - SheetImportSource source = loadSheetImportSource(spreadsheetId, analysis.memberListMapping()); - return executeReadOnlyTransaction(() -> { - Club club = clubRepository.getById(clubId); - SheetImportPlan plan = buildImportPlan( - clubId, - club, - source.members(), - source.warnings() - ); - return SheetImportPreviewResponse.of(plan.previewMembers(), plan.warnings()); - }); + private Club resolveClubWithRegisteredSheet(Integer clubId) { + Club club = clubRepository.getByIdWithUniversity(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + return club; + } + + private SheetColumnMapping resolveRegisteredMemberListMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } + + try { + Map raw = objectMapper.readValue(mappingJson, new TypeReference<>() {}); + int dataStartRow = raw.containsKey("dataStartRow") + ? ((Number)raw.get("dataStartRow")).intValue() : 2; + Map fieldMap = new HashMap<>(); + raw.forEach((key, value) -> { + if (!"dataStartRow".equals(key) && value instanceof Number number) { + fieldMap.put(key, number.intValue()); + } + }); + return new SheetColumnMapping(fieldMap, dataStartRow); + } catch (Exception e) { + throw CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + } } @Transactional diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index d62a0fdfa..d2999a5f5 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -49,6 +49,8 @@ public enum ApiResponseCode { INVALID_NOTIFICATION_TOKEN(HttpStatus.BAD_REQUEST, "푸시 알림 토큰이 유효하지 않습니다."), FEE_PAYMENT_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "회비 납부가 필요한 동아리입니다. 납부 증빙 사진을 첨부해주세요."), AMBIGUOUS_USER_MATCH(HttpStatus.BAD_REQUEST, "동일한 정보로 식별되는 사용자가 2명 이상입니다. 관리자에게 문의해주세요."), + CLUB_SHEET_ANALYSIS_REQUIRED(HttpStatus.BAD_REQUEST, + "구글 시트 파일에서 동아리 부원을 가져오기 전에 먼저 AI 분석 및 등록이 완료되어야 합니다."), // 401 Unauthorized INVALID_SESSION(HttpStatus.UNAUTHORIZED, "올바르지 않은 인증 정보 입니다."), diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java index b54422755..0adc652c7 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetImportServiceTest.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.club.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anySet; @@ -15,9 +16,10 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.SimpleTransactionStatus; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; @@ -33,6 +35,8 @@ import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; import gg.agit.konect.support.fixture.UniversityFixture; @@ -43,8 +47,6 @@ class SheetImportServiceTest extends ServiceTestSupport { private static final Integer CLUB_ID = 1; private static final Integer REQUESTER_ID = 2; private static final String SPREADSHEET_ID = "sheet-id"; - private static final String SPREADSHEET_URL = - "https://docs.google.com/spreadsheets/d/" + SPREADSHEET_ID + "/edit"; @Mock private Sheets googleSheetsService; @@ -82,23 +84,26 @@ class SheetImportServiceTest extends ServiceTestSupport { @Mock private PlatformTransactionManager transactionManager; + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + @InjectMocks private SheetImportService sheetImportService; @Test void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(SPREADSHEET_ID); + club.updateSheetColumnMapping(objectMapper.writeValueAsString( + SheetColumnMapping.defaultMapping().toMap() + )); User directUser = UserFixture.createUser(club.getUniversity(), "Alex Kim", "2021232948"); - given(clubRepository.getById(CLUB_ID)).willReturn(club); - given(sheetHeaderMapper.analyzeAllSheets(SPREADSHEET_ID)).willReturn( - new SheetHeaderMapper.SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null) - ); + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); given(clubMemberRepository.findStudentNumbersByClubId(CLUB_ID)).willReturn(Set.of()); given(clubPreMemberRepository.findStudentNumberAndNameByClubId(CLUB_ID)) .willReturn(List.of()); given(clubMemberRepository.findUserIdsByClubId(CLUB_ID)).willReturn(List.of()); - given(transactionManager.getTransaction(any())).willReturn(new SimpleTransactionStatus()); given(userRepository.findAllByUniversityIdAndStudentNumberIn( eq(club.getUniversity().getId()), anySet() @@ -115,8 +120,7 @@ void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { SheetImportPreviewResponse response = sheetImportService.previewPreMembersFromSheet( CLUB_ID, - REQUESTER_ID, - SPREADSHEET_URL + REQUESTER_ID ); assertThat(response.previewCount()).isEqualTo(2); @@ -133,6 +137,35 @@ void previewPreMembersFromSheetReturnsDirectAndPreMembers() throws IOException { .containsExactly(true, true); } + @Test + void previewPreMembersFromSheetThrowsWhenSheetIsNotRegistered() { + Club club = ClubFixture.create(UniversityFixture.create()); + + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); + + assertThatThrownBy(() -> sheetImportService.previewPreMembersFromSheet(CLUB_ID, REQUESTER_ID)) + .isInstanceOf(CustomException.class) + .extracting(exception -> ((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + + verifyNoInteractions(googleSheetsService, sheetHeaderMapper); + } + + @Test + void previewPreMembersFromSheetThrowsWhenSheetMappingIsMissing() { + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(SPREADSHEET_ID); + + given(clubRepository.getByIdWithUniversity(CLUB_ID)).willReturn(club); + + assertThatThrownBy(() -> sheetImportService.previewPreMembersFromSheet(CLUB_ID, REQUESTER_ID)) + .isInstanceOf(CustomException.class) + .extracting(exception -> ((CustomException)exception).getErrorCode()) + .isEqualTo(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED); + + verifyNoInteractions(googleSheetsService, sheetHeaderMapper); + } + @Test void confirmImportPreMembersImportsOnlyEnabledMembers() { Club club = ClubFixture.create(UniversityFixture.create()); diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java index 6ce72c97b..d38987caa 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubSheetMigrationApiTest.java @@ -71,13 +71,10 @@ void previewPreMembersSuccess() throws Exception { given(sheetImportService.previewPreMembersFromSheet( eq(CLUB_ID), - eq(REQUESTER_ID), - eq(SPREADSHEET_URL) + eq(REQUESTER_ID) )).willReturn(response); - SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); - - performPost("/clubs/" + CLUB_ID + "/sheet/import/preview", request) + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") .andExpect(status().isOk()) .andExpect(jsonPath("$.previewCount").value(2)) .andExpect(jsonPath("$.autoRegisteredCount").value(1)) @@ -96,19 +93,32 @@ void previewPreMembersSuccess() throws Exception { void previewPreMembersForbiddenGoogleSheetAccess() throws Exception { given(sheetImportService.previewPreMembersFromSheet( eq(CLUB_ID), - eq(REQUESTER_ID), - eq(SPREADSHEET_URL) + eq(REQUESTER_ID) )).willThrow(CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS)); - SheetImportRequest request = new SheetImportRequest(SPREADSHEET_URL); - - performPost("/clubs/" + CLUB_ID + "/sheet/import/preview", request) + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") .andExpect(status().isForbidden()) .andExpect(jsonPath("$.code") .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.name())) .andExpect(jsonPath("$.message") .value(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS.getMessage())); } + + @Test + @DisplayName("returns 400 when sheet analysis and registration are not completed") + void previewPreMembersRequiresRegisteredSheet() throws Exception { + given(sheetImportService.previewPreMembersFromSheet( + eq(CLUB_ID), + eq(REQUESTER_ID) + )).willThrow(CustomException.of(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED)); + + performPost("/clubs/" + CLUB_ID + "/sheet/import/preview") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code") + .value(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED.name())) + .andExpect(jsonPath("$.message") + .value(ApiResponseCode.CLUB_SHEET_ANALYSIS_REQUIRED.getMessage())); + } } @Nested